From 3795dbb01723d7474db8ddf805b1f64d5b56919c Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 20 Jul 2026 03:31:13 -0500 Subject: [PATCH 001/110] fix(server): resolve Claude SDK executable path on Windows npm installs (#3740) --- .../provider/Drivers/ClaudeExecutable.test.ts | 124 ++++++++++++++++++ .../src/provider/Drivers/ClaudeExecutable.ts | 90 +++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 7 +- .../src/provider/Layers/ClaudeProvider.ts | 7 +- 4 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/Drivers/ClaudeExecutable.test.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeExecutable.ts diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts new file mode 100644 index 00000000000..020fc48a465 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; + +import { ClaudeExecutableFileCheck, resolveClaudeSdkExecutablePath } from "./ClaudeExecutable.ts"; + +const NPM_DIR = "C:\\Users\\dev\\AppData\\Roaming\\npm"; +const NPM_SHIM = `${NPM_DIR}\\claude.cmd`; +const NPM_PACKAGE_EXE = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; +const NPM_PACKAGE_CLI = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`; + +function withWindowsResolution(input: { + readonly resolvedCommand: string | undefined; + readonly existingFiles?: ReadonlyArray; +}) { + const existing = new Set(input.existingFiles ?? []); + return (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand), + Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)), + ); +} + +describe("resolveClaudeSdkExecutablePath", () => { + it.effect("returns the configured path unchanged on non-Windows platforms", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(SpawnExecutableResolution, () => { + throw new Error("must not resolve on non-Windows platforms"); + }), + ), + ).toBe("claude"); + }), + ); + + it.effect("returns the resolved absolute path for native Windows executables", () => + Effect.gen(function* () { + const nativeBinary = "C:\\Users\\dev\\.local\\bin\\claude.exe"; + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ resolvedCommand: nativeBinary }), + ), + ).toBe(nativeBinary); + }), + ); + + it.effect("follows an npm launcher shim to the packaged native binary", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: NPM_SHIM, + existingFiles: [NPM_PACKAGE_EXE, NPM_PACKAGE_CLI], + }), + ), + ).toBe(NPM_PACKAGE_EXE); + }), + ); + + it.effect("follows .bat and .ps1 launcher shims the same way", () => + Effect.gen(function* () { + for (const shim of [`${NPM_DIR}\\claude.bat`, `${NPM_DIR}\\claude.ps1`]) { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: shim, + existingFiles: [NPM_PACKAGE_EXE], + }), + ), + ).toBe(NPM_PACKAGE_EXE); + } + }), + ); + + it.effect("normalizes mixed-case shim extensions before matching", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: `${NPM_DIR}\\claude.CMD`, + existingFiles: [NPM_PACKAGE_EXE], + }), + ), + ).toBe(NPM_PACKAGE_EXE); + }), + ); + + it.effect("falls back to cli.js when the package ships no native binary", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: NPM_SHIM, + existingFiles: [NPM_PACKAGE_CLI], + }), + ), + ).toBe(NPM_PACKAGE_CLI); + }), + ); + + it.effect("returns the configured path when a shim has no known package entry", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ resolvedCommand: NPM_SHIM }), + ), + ).toBe("claude"); + }), + ); + + it.effect("returns the configured path when command resolution finds nothing", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ resolvedCommand: undefined }), + ), + ).toBe("claude"); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts new file mode 100644 index 00000000000..febfdb26f9e --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -0,0 +1,90 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +/** + * Windows launcher-script extensions that Node cannot spawn without a shell + * (`spawn EINVAL` since Node 20.12) and that the Claude Agent SDK therefore + * cannot use as `pathToClaudeCodeExecutable`. + */ +const WINDOWS_SHIM_EXTENSIONS: ReadonlySet = new Set([".cmd", ".bat", ".ps1"]); + +/** + * Entry points of the npm `@anthropic-ai/claude-code` package relative to the + * global `node_modules` directory that sits next to the npm launcher shim. + * Newer package versions ship a native `bin/claude.exe`; older versions only + * ship `cli.js`, which the SDK runs with a JavaScript runtime. + */ +const NPM_PACKAGE_ENTRY_CANDIDATES = [ + ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"], + ["node_modules", "@anthropic-ai", "claude-code", "cli.js"], +] as const; + +export type ExecutableFileCheck = (filePath: string) => boolean; + +function isExistingFile(filePath: string): boolean { + try { + return NodeFS.statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** Injectable file-existence check so tests can run against a fake filesystem. */ +export const ClaudeExecutableFileCheck = Context.Reference( + "server/provider/Drivers/ClaudeExecutableFileCheck", + { + defaultValue: () => isExistingFile, + }, +); + +/** + * Resolves the configured Claude binary path into a value the Claude Agent + * SDK can spawn directly via `pathToClaudeCodeExecutable`. + * + * The SDK spawns the given path without a shell and without Windows PATH / + * PATHEXT resolution, so a bare command name like `claude` fails with + * "native binary not found" and an npm `claude.cmd` shim fails with + * `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, which can + * fall back to `shell: true`; the SDK offers no such escape hatch. + * + * On Windows this resolves the command against PATH/PATHEXT and, when the + * result is an npm launcher shim, follows it to the real package entry + * (`bin/claude.exe`, or `cli.js` for older package versions). On other + * platforms the configured value is returned unchanged. + */ +export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")( + function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + if (platform !== "win32") { + return binaryPath; + } + + const resolveExecutable = yield* SpawnExecutableResolution; + const isFile = yield* ClaudeExecutableFileCheck; + const resolved = resolveExecutable(binaryPath, platform, environment) ?? binaryPath; + const extension = NodePath.win32.extname(resolved).toLowerCase(); + if (!WINDOWS_SHIM_EXTENSIONS.has(extension)) { + return resolved; + } + + const shimDirectory = NodePath.win32.dirname(resolved); + for (const entrySegments of NPM_PACKAGE_ENTRY_CANDIDATES) { + const candidate = NodePath.win32.join(shimDirectory, ...entrySegments); + if (isFile(candidate)) { + return candidate; + } + } + + yield* Effect.logWarning( + "Claude launcher shim resolved but no known package entry was found next to it; the Claude Agent SDK cannot spawn launcher scripts directly.", + { binaryPath, resolvedShimPath: resolved }, + ); + return binaryPath; + }, +); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 97a93f85829..f6e63eeffad 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -70,6 +70,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, @@ -1347,6 +1348,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( Effect.provideService(Path.Path, path), ); + const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath( + claudeSettings.binaryPath, + claudeEnvironment, + ); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -3405,7 +3410,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); - const claudeBinaryPath = claudeSettings.binaryPath; + const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; const modelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index af8f5d6704b..d49b4770a09 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -37,6 +37,7 @@ import { spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ @@ -588,6 +589,10 @@ const probeClaudeCapabilities = ( const abort = new AbortController(); return Effect.gen(function* () { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const executablePath = yield* resolveClaudeSdkExecutablePath( + claudeSettings.binaryPath, + claudeEnvironment, + ); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -598,7 +603,7 @@ const probeClaudeCapabilities = ( })(), options: { persistSession: false, - pathToClaudeCodeExecutable: claudeSettings.binaryPath, + pathToClaudeCodeExecutable: executablePath, abortController: abort, settingSources: ["user", "project", "local"], allowedTools: [], From 63b6b44627d605afba7ec2549f817c23c0f903ad Mon Sep 17 00:00:00 2001 From: coach007 <6238600+keeperxy@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:32:14 +0200 Subject: [PATCH 002/110] Fix project action preview settings persistence (#3842) --- apps/web/src/components/ChatView.tsx | 17 ++---------- apps/web/src/projectScripts.test.ts | 41 ++++++++++++++++++++++++++++ apps/web/src/projectScripts.ts | 25 +++++++++++++++++ 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f175a21a131..c296c717066 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -145,6 +145,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { + buildProjectScript, commandForProjectScript, nextProjectScriptId, projectScriptIdFromCommand, @@ -2752,13 +2753,7 @@ function ChatViewContent(props: ChatViewProps) { input.name, activeProject.scripts.map((script) => script.id), ); - const nextScript: ProjectScript = { - id: nextId, - name: input.name, - command: input.command, - icon: input.icon, - runOnWorktreeCreate: input.runOnWorktreeCreate, - }; + const nextScript = buildProjectScript(nextId, input); const nextScripts = input.runOnWorktreeCreate ? [ ...activeProject.scripts.map((script) => @@ -2792,13 +2787,7 @@ function ChatViewContent(props: ChatViewProps) { return AsyncResult.failure(Cause.fail(new Error("Script not found."))); } - const updatedScript: ProjectScript = { - ...existingScript, - name: input.name, - command: input.command, - icon: input.icon, - runOnWorktreeCreate: input.runOnWorktreeCreate, - }; + const updatedScript = buildProjectScript(existingScript.id, input); const nextScripts = activeProject.scripts.map((script) => script.id === scriptId ? updatedScript diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index 3597b714c77..1f7a6bfaa9f 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/shared/projectScripts"; import { + buildProjectScript, commandForProjectScript, nextProjectScriptId, primaryProjectScript, @@ -13,6 +14,46 @@ import { } from "./projectScripts"; describe("projectScripts helpers", () => { + it("builds scripts with preview settings", () => { + expect( + buildProjectScript("dev", { + name: "Dev server", + command: "pnpm dev", + icon: "debug", + runOnWorktreeCreate: false, + previewUrl: "http://localhost:5733", + autoOpenPreview: true, + }), + ).toEqual({ + id: "dev", + name: "Dev server", + command: "pnpm dev", + icon: "debug", + runOnWorktreeCreate: false, + previewUrl: "http://localhost:5733", + autoOpenPreview: true, + }); + }); + + it("omits preview settings when no preview URL is configured", () => { + expect( + buildProjectScript("test", { + name: "Test", + command: "pnpm test", + icon: "test", + runOnWorktreeCreate: false, + previewUrl: null, + autoOpenPreview: false, + }), + ).toEqual({ + id: "test", + name: "Test", + command: "pnpm test", + icon: "test", + runOnWorktreeCreate: false, + }); + }); + it("builds and parses script run commands", () => { const command = commandForProjectScript("lint"); expect(command).toBe("script.lint.run"); diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index f0aeead1411..e3efc9e3a33 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -7,6 +7,31 @@ import { import * as Schema from "effect/Schema"; const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); +export interface ProjectScriptInput { + readonly name: ProjectScript["name"]; + readonly command: ProjectScript["command"]; + readonly icon: ProjectScript["icon"]; + readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"]; + readonly previewUrl: Exclude | null; + readonly autoOpenPreview: boolean; +} + +export function buildProjectScript(id: string, input: ProjectScriptInput): ProjectScript { + return { + id, + name: input.name, + command: input.command, + icon: input.icon, + runOnWorktreeCreate: input.runOnWorktreeCreate, + ...(input.previewUrl === null + ? {} + : { + previewUrl: input.previewUrl, + autoOpenPreview: input.autoOpenPreview, + }), + }; +} + function normalizeScriptId(value: string): string { const cleaned = value .trim() From 78485e6d50d06180618aacd0d25151e831f666a4 Mon Sep 17 00:00:00 2001 From: Carlos Rico-Ospina Date: Mon, 20 Jul 2026 04:32:44 -0400 Subject: [PATCH 003/110] fix(desktop): allow clipboard writes in the preview browser (#3889) --- .../src/preview/BrowserSession.test.ts | 51 +++++++++++++++++++ apps/desktop/src/preview/BrowserSession.ts | 20 +++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index e258bb2dfc5..743fd6a1fce 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -15,6 +15,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ readonly clearStorageData: ReturnType; readonly getUserAgent: ReturnType; readonly setPermissionRequestHandler: ReturnType; + readonly setPermissionCheckHandler: ReturnType; readonly setUserAgent: ReturnType; } >(), @@ -40,6 +41,7 @@ describe("BrowserSession", () => { clearStorageData: vi.fn(() => Promise.resolve()), getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"), setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), setUserAgent: vi.fn(), }; sessions.set(partition, browserSession); @@ -61,6 +63,55 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + + const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0]; + const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + assert.isFunction(checkHandler); + + const requestAllows = (permission: string): boolean => { + let granted: boolean | undefined; + requestHandler(null, permission, (value: boolean) => { + granted = value; + }); + assert.isDefined(granted); + return granted; + }; + + for (const permission of [ + "clipboard-read", + "clipboard-sanitized-write", + "notifications", + "geolocation", + ]) { + assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); + assert.isTrue( + checkHandler(null, permission) as boolean, + `check handler should allow ${permission}`, + ); + } + + // `clipboard-write` is not a real Electron permission — the async write API + // uses `clipboard-sanitized-write` — so the stale name must not be granted, + // and unrelated permissions stay denied. + for (const permission of ["clipboard-write", "midi"]) { + assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`); + assert.isFalse( + checkHandler(null, permission) as boolean, + `check handler should deny ${permission}`, + ); + } + }).pipe(Effect.provide(layer)), + ); + it.effect("preserves partition scope and the platform failure chain", () => { const nativeCause = new Error("native digest failed"); const platformCause = PlatformError.systemError({ diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index afa8dafe976..aa0b0743e93 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -11,6 +11,20 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; +// Permissions granted to preview web content. `clipboard-sanitized-write` is the +// Electron permission behind `navigator.clipboard.writeText()` — note it is NOT +// `clipboard-write`, which is not a valid Electron permission name. Async +// clipboard writes are gated by the permission *check* handler (not only the +// request handler), so both handlers must allow it; otherwise built-in "Copy" +// buttons — e.g. the Next.js / Vercel error overlay — fail with +// `Failed to execute 'writeText' on 'Clipboard': Write permission denied`. +const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ + "clipboard-read", + "clipboard-sanitized-write", + "notifications", + "geolocation", +]); + export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( "BrowserSessionPartitionDerivationError", { @@ -120,9 +134,11 @@ export const make = Effect.gen(function* BrowserSessionMake() { .replace(/\s*t3code\/[\d.]+/, ""); browserSession.setUserAgent(userAgent); browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; - callback(allowed.includes(permission)); + callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); }); + browserSession.setPermissionCheckHandler((_webContents, permission) => + ALLOWED_PREVIEW_PERMISSIONS.has(permission), + ); const next = new Map(sessions); next.set(partition, browserSession); return [browserSession, next] as const; From dfbda843628c817a48523b1a4a28a6ef232d1d65 Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Mon, 20 Jul 2026 04:33:43 -0400 Subject: [PATCH 004/110] fix(web): handle sidebar shortcut before editors (#3921) --- apps/web/src/components/AppSidebarLayout.tsx | 11 +++++++++-- .../src/components/settings/KeybindingsSettings.tsx | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 587ec65cbc9..4d0486aa866 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -33,6 +33,12 @@ function SidebarControl() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; + if ( + event.target instanceof HTMLElement && + event.target.closest("[data-keybinding-capture]") + ) { + return; + } if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; event.preventDefault(); @@ -40,8 +46,9 @@ function SidebarControl() { toggleSidebar(); }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + // Capture before focused editors consume commands such as Mod+B for rich-text formatting. + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); }, [keybindings, toggleSidebar]); return ( diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index b7dbbd3575b..67c33c9b942 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -846,6 +846,7 @@ function KeybindingTableRow({ ) : (
Date: Mon, 20 Jul 2026 10:35:08 +0200 Subject: [PATCH 005/110] fix(server): recognize Bedrock-backed Claude as authenticated (#3931) --- .../src/provider/Layers/ClaudeProvider.ts | 29 +++++++++++++++---- .../provider/Layers/ProviderRegistry.test.ts | 26 +++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d49b4770a09..94b24405eee 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -480,9 +480,19 @@ function claudeAuthMetadata(input: { return undefined; } +function apiProviderAuthMetadata( + apiProvider: string | undefined, +): { readonly type: string; readonly label: string } | undefined { + return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; +} + // ── SDK capability probe ──────────────────────────────────────────── -const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; +// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the +// Bedrock backend and runs the `awsAuthRefresh` credential hook before returning +// account info. The previous 8s budget expired mid-init, so the probe returned +// `undefined` and left the provider unverified and unselectable in the picker. +const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; function nonEmptyProbeString(value: string): string | undefined { const candidate = value.trim(); @@ -493,6 +503,12 @@ type ClaudeCapabilitiesProbe = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + /** + * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth + * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) + * the subscription/token fields are absent and auth is external AWS creds. + */ + readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; }; @@ -618,12 +634,14 @@ const probeClaudeCapabilities = ( readonly email?: string; readonly subscriptionType?: string; readonly tokenSource?: string; + readonly apiProvider?: string; } | undefined; return { email: account?.email, subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, + apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), } satisfies ClaudeCapabilitiesProbe; }); @@ -799,10 +817,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const authMetadata = claudeAuthMetadata({ - subscriptionType: capabilities.subscriptionType, - authMethod: capabilities.tokenSource, - }); + const authMetadata = + claudeAuthMetadata({ + subscriptionType: capabilities.subscriptionType, + authMethod: capabilities.tokenSource, + }) ?? apiProviderAuthMetadata(capabilities.apiProvider); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5456a90bdf5..78ff5cf493d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -100,6 +100,7 @@ type TestClaudeCapabilities = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; }; @@ -109,6 +110,7 @@ function claudeCapabilities(overrides: Partial = {}) { email: undefined, subscriptionType: undefined, tokenSource: undefined, + apiProvider: undefined, slashCommands: [], ...overrides, }); @@ -1492,6 +1494,30 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => + Effect.gen(function* () { + // Bedrock authenticates via external AWS credentials, so the SDK init + // reports only `apiProvider` with no subscription or token. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ apiProvider: "bedrock" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "bedrock"); + assert.strictEqual(status.auth.label, "Amazon Bedrock"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("includes Claude Fable 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( From d7baa37e5c25a1335a117ee3c9f5b5b0d5bbd832 Mon Sep 17 00:00:00 2001 From: mel Date: Mon, 20 Jul 2026 10:35:47 +0200 Subject: [PATCH 006/110] =?UTF-8?q?Fix=20incorrect=20pluralization=20of=20?= =?UTF-8?q?=E2=80=9Centry=E2=80=9D=20(#3933)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/components/chat/MessagesTimeline.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d67dd286faa..46d6a4050b8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1157,7 +1157,13 @@ function WorkGroupToggleTimelineRow({ row: Extract; }) { const ctx = use(TimelineRowCtx); - const labelNoun = row.onlyToolEntries ? "tool call" : "log entry"; + const labelNoun = row.onlyToolEntries + ? row.hiddenCount === 1 + ? "tool call" + : "tool calls" + : row.hiddenCount === 1 + ? "log entry" + : "log entries"; return ( From 749baec353d5675f1acac81f22aa15da573e71c7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 01:42:02 -0700 Subject: [PATCH 007/110] feat(server): title background-task work-log rows with the task name (#3751) Co-authored-by: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.test.ts | 190 ++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 102 +++++++++- 2 files changed, 289 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 001ba388949..72976768fec 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2947,6 +2947,196 @@ describe("ProviderRuntimeIngestion", () => { ).toBe("# Plan title"); }); + it("titles task activities with the task description, including on completion", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-named-task-started"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-named-task"), + payload: { + taskId: "named-task-1", + description: "Typecheck mobile app", + taskType: "local_bash", + }, + }); + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-named-task-progress"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-named-task"), + payload: { + taskId: "named-task-1", + description: "Typecheck mobile app", + summary: "Running tsc across the mobile workspace.", + }, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-named-task-completed"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-named-task"), + payload: { + taskId: "named-task-1", + status: "completed", + summary: "Typecheck finished without errors.", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", + ), + ); + + const progress = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-progress", + ); + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed", + ); + + const progressPayload = + progress?.payload && typeof progress.payload === "object" + ? (progress.payload as Record) + : undefined; + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(progress?.summary).toBe("Typecheck mobile app"); + expect(progressPayload?.title).toBe("Typecheck mobile app"); + expect(completed?.summary).toBe("Task completed"); + expect(completedPayload?.title).toBe("Typecheck mobile app"); + expect(completedPayload?.summary).toBe("Typecheck finished without errors."); + expect(completedPayload?.detail).toBe("Typecheck finished without errors."); + }); + + it("titles task completion from task.started when no progress event carried the name", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-fast-task-started"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-fast-task"), + payload: { + taskId: "fast-task-1", + description: "wait for codex review to finish", + taskType: "local_bash", + }, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-fast-task-completed"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-fast-task"), + payload: { + taskId: "fast-task-1", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-fast-task-completed", + ), + ); + + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-fast-task-completed", + ); + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(completedPayload?.title).toBe("wait for codex review to finish"); + }); + + it("titles task completion from persisted activities after the description cache is swept", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-swept-task-progress"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-swept-task"), + payload: { + taskId: "swept-task-1", + description: "Watch round-3 CI and bots", + summary: "Polling CI checks.", + }, + }); + + await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-progress", + ), + ); + + // session.exited sweeps the in-memory description cache; the completion + // that follows must recover the name from persisted activities. + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-swept-task-session-exited"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: {}, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-swept-task-completed"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-swept-task"), + payload: { + taskId: "swept-task-1", + status: "completed", + summary: "CI is green.", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", + ), + ); + + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", + ); + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(completedPayload?.title).toBe("Watch round-3 CI and bots"); + }); + it("projects structured user input request and resolution as thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3e5978f4846..ef3454348e4 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -40,6 +40,42 @@ import { import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; +const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; + +// Fallback when the in-memory description cache no longer has the task name +// (server restart, session-exit sweep, TTL/capacity eviction): earlier +// task.started/task.progress activities for the task are persisted with it. +function findTaskTitleInActivities( + activities: ReadonlyArray | undefined, + taskId: string, +): string | undefined { + if (!activities) { + return undefined; + } + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if (!activity || (activity.kind !== "task.started" && activity.kind !== "task.progress")) { + continue; + } + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as { taskId?: unknown; title?: unknown; detail?: unknown }) + : undefined; + if (payload?.taskId !== taskId) { + continue; + } + const title = + typeof payload.title === "string" + ? payload.title + : activity.kind === "task.started" && typeof payload.detail === "string" + ? payload.detail + : undefined; + if (title && title.trim().length > 0) { + return title; + } + } + return undefined; +} interface AssistantSegmentState { baseKey: string; @@ -53,6 +89,8 @@ const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); +const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000; +const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; @@ -264,6 +302,7 @@ function requestKindFromCanonicalRequestType( function runtimeEventToActivities( event: ProviderRuntimeEvent, + taskTitle?: string, ): ReadonlyArray { const maybeSequence = (() => { const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; @@ -473,9 +512,15 @@ function runtimeEventToActivities( createdAt: event.createdAt, tone: "info", kind: "task.progress", - summary: "Reasoning update", + summary: + event.payload.description.trim().length > 0 + ? truncateDetail(event.payload.description, 120) + : "Reasoning update", payload: { taskId: event.payload.taskId, + ...(event.payload.description.trim().length > 0 + ? { title: truncateDetail(event.payload.description, 120) } + : {}), detail: truncateDetail(event.payload.summary ?? event.payload.description), ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), @@ -503,7 +548,15 @@ function runtimeEventToActivities( payload: { taskId: event.payload.taskId, status: event.payload.status, - ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}), + ...(taskTitle ? { title: truncateDetail(taskTitle, 120) } : {}), + // summary + detail mirror task.progress: clients label the row from + // summary and keep detail for the preview/expanded body. + ...(event.payload.summary + ? { + summary: truncateDetail(event.payload.summary), + detail: truncateDetail(event.payload.summary), + } + : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), }, turnId: toTurnId(event.turnId) ?? null, @@ -666,6 +719,27 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); + // Task names arrive on task.started/task.progress but not on task.completed, + // so remember them per task to title the completion activity. + const taskDescriptionByTaskKey = yield* Cache.make({ + capacity: TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY, + timeToLive: TASK_DESCRIPTION_BY_TASK_TTL, + lookup: () => Effect.succeed(""), + }); + + const rememberTaskDescription = (threadId: ThreadId, taskId: string, description: string) => + Cache.set(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId), description); + + // Entries are left in place after completion so replayed or duplicate + // terminal events stay titled; TTL, capacity, and the session-exit sweep + // bound the cache. + const lookupTaskDescription = (threadId: ThreadId, taskId: string) => + Cache.getOption(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId)).pipe( + Effect.map((description) => + Option.filter(description, (value) => value.length > 0).pipe(Option.getOrUndefined), + ), + ); + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery .getThreadDetailById(threadId) @@ -1090,6 +1164,7 @@ const make = Effect.gen(function* () { const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey)); const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); + const taskDescriptionKeys = Array.from(yield* Cache.keys(taskDescriptionByTaskKey)); yield* Effect.forEach( turnKeys, (key) => @@ -1125,6 +1200,12 @@ const make = Effect.gen(function* () { : Effect.void, { concurrency: 1 }, ).pipe(Effect.asVoid); + yield* Effect.forEach( + taskDescriptionKeys, + (key) => + key.startsWith(prefix) ? Cache.invalidate(taskDescriptionByTaskKey, key) : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); }); const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn( @@ -1654,7 +1735,22 @@ const make = Effect.gen(function* () { } } - const activities = runtimeEventToActivities(event); + if (event.type === "task.started" || event.type === "task.progress") { + const description = event.payload.description?.trim(); + if (description) { + yield* rememberTaskDescription(thread.id, event.payload.taskId, description); + } + } + let taskTitle: string | undefined; + if (event.type === "task.completed") { + taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); + if (!taskTitle) { + const threadDetail = yield* getLoadedThreadDetail(); + taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); + } + } + + const activities = runtimeEventToActivities(event, taskTitle); yield* Effect.forEach(activities, (activity) => providerCommandId(event, "thread-activity-append").pipe( Effect.flatMap((commandId) => From 3235658c080bc12fcd1ffaa275aced98d225f2f6 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Mon, 20 Jul 2026 09:43:07 +0100 Subject: [PATCH 008/110] fix: delegate OpenCode session titles to provider (#3720) --- .../provider/Layers/OpenCodeAdapter.test.ts | 46 +++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 53 +++++++++++++++++-- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index b227ff1ab66..4358cf305b0 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -54,6 +54,7 @@ const runtimeMock = { state: { startCalls: [] as string[], sessionCreateUrls: [] as string[], + sessionCreateInputs: [] as Array>, authHeaders: [] as Array, abortCalls: [] as string[], closeCalls: [] as string[], @@ -67,6 +68,7 @@ const runtimeMock = { reset() { this.state.startCalls.length = 0; this.state.sessionCreateUrls.length = 0; + this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; this.state.closeCalls.length = 0; @@ -122,8 +124,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { - create: async () => { + create: async (input: Record) => { runtimeMock.state.sessionCreateUrls.push(baseUrl); + runtimeMock.state.sessionCreateInputs.push(input); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); @@ -765,6 +768,47 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-title-sync"); + runtimeMock.state.subscribedEvents = [ + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "Investigate OpenCode title sync", + }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); + NodeAssert.equal("title" in (runtimeMock.state.sessionCreateInputs[0] ?? {}), false); + + const metadataUpdated = events.find((event) => event.type === "thread.metadata.updated"); + NodeAssert.ok(metadataUpdated); + if (metadataUpdated.type === "thread.metadata.updated") { + NodeAssert.equal(metadataUpdated.payload.name, "Investigate OpenCode title sync"); + } + }), + ); + it.effect("writes provider-native observability records using the session thread id", () => Effect.gen(function* () { const nativeEvents: Array<{ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 956905e3a3c..e7622e6c705 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -65,6 +65,35 @@ type OpenCodeSubscribedEvent = ? TEvent : never; +function trimText(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function openCodeEventSessionId(event: OpenCodeSubscribedEvent): string | undefined { + const properties = "properties" in event ? event.properties : undefined; + if (!properties || typeof properties !== "object") { + return undefined; + } + + const sessionID = (properties as { readonly sessionID?: unknown }).sessionID; + const sessionIDFromProperties = typeof sessionID === "string" ? sessionID : undefined; + if (sessionIDFromProperties) { + return sessionIDFromProperties; + } + + const info = (properties as { readonly info?: { readonly id?: unknown } }).info; + return info && typeof info.id === "string" ? info.id : undefined; +} + +function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | undefined { + if (event.type !== "session.updated") { + return undefined; + } + + return trimText(event.properties.info.title); +} + interface OpenCodeSessionContext { session: ProviderSession; readonly client: OpencodeClient; @@ -643,8 +672,7 @@ export function makeOpenCodeAdapter( context: OpenCodeSessionContext, event: OpenCodeSubscribedEvent, ) { - const payloadSessionId = - "properties" in event ? (event.properties as { sessionID?: unknown }).sessionID : undefined; + const payloadSessionId = openCodeEventSessionId(event); if (payloadSessionId !== context.openCodeSessionId) { return; } @@ -663,6 +691,26 @@ export function makeOpenCodeAdapter( }); switch (event.type) { + case "session.updated": { + const title = openCodeEventSessionTitle(event); + if (title) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + raw: event, + })), + type: "thread.metadata.updated", + payload: { + name: title, + metadata: { + sessionID: context.openCodeSessionId, + }, + }, + }); + } + break; + } + case "message.updated": { context.messageRoleById.set(event.properties.info.id, event.properties.info.role); if (event.properties.info.role === "assistant") { @@ -1069,7 +1117,6 @@ export function makeOpenCodeAdapter( } const openCodeSession = yield* runOpenCodeSdk("session.create", () => client.session.create({ - title: `T3 Code ${input.threadId}`, permission: buildOpenCodePermissionRules(input.runtimeMode), }), ); From df65a6c3ad605feb2f326c0ad78d056c10476770 Mon Sep 17 00:00:00 2001 From: Christoph Herzog Date: Mon, 20 Jul 2026 10:51:22 +0200 Subject: [PATCH 009/110] Archive selected threads from the context menu (#3895) --- apps/web/src/components/Sidebar.logic.test.ts | 69 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 50 ++++++++++++++ apps/web/src/components/Sidebar.tsx | 69 ++++++++++++++++--- apps/web/src/hooks/useThreadActions.ts | 6 +- 4 files changed, 180 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5f6c234e5f6..1dd4e340125 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, @@ -38,6 +40,73 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("archiveSelectedThreadEntries", () => { + const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; + const success = { _tag: "Success" } as const; + const failure = { _tag: "Failure" } as const; + + it("records every entry after full success", async () => { + const outcome = await archiveSelectedThreadEntries({ + entries, + archive: async (_entry, onArchived) => { + onArchived(); + return success; + }, + }); + + expect(outcome).toEqual({ + archivedThreadKeys: ["one", "two", "three"], + mutationFailure: null, + followupFailures: [], + }); + }); + + it("stops at a mutation failure and retains prior successes", async () => { + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + if (entry.threadKey === "two") return failure; + onArchived(); + return success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(2); + expect(outcome).toEqual({ + archivedThreadKeys: ["one"], + mutationFailure: failure, + followupFailures: [], + }); + }); + + it("continues after a post-archive failure", async () => { + const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { + onArchived(); + return entry.threadKey === "two" ? failure : success; + }); + const outcome = await archiveSelectedThreadEntries({ entries, archive }); + + expect(archive).toHaveBeenCalledTimes(3); + expect(outcome).toEqual({ + archivedThreadKeys: ["one", "two", "three"], + mutationFailure: null, + followupFailures: [failure], + }); + }); +}); + +describe("buildMultiSelectThreadContextMenuItems", () => { + it("offers bulk archive with the selected count", () => { + expect( + buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }), + ).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false }); + }); + + it("disables bulk archive when a selected thread is running", () => { + expect( + buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }), + ).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true }); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 91a61cc1558..1a565efd878 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, @@ -36,6 +37,55 @@ type ScopedSidebarThread = ThreadSortInput & { export type ThreadTraversalDirection = "previous" | "next"; +export async function archiveSelectedThreadEntries< + TEntry extends { readonly threadKey: string }, + TResult extends { readonly _tag: "Success" | "Failure" }, +>(input: { + entries: readonly TEntry[]; + archive: (entry: TEntry, onArchived: () => void) => Promise; +}): Promise<{ + archivedThreadKeys: readonly string[]; + mutationFailure: Extract | null; + followupFailures: readonly Extract[]; +}> { + const archivedThreadKeys: string[] = []; + const followupFailures: Extract[] = []; + + for (const entry of input.entries) { + let didArchive = false; + const result = await input.archive(entry, () => { + didArchive = true; + }); + if (didArchive || result._tag === "Success") { + archivedThreadKeys.push(entry.threadKey); + } + if (result._tag === "Success") continue; + const failure = result as Extract; + if (didArchive) { + followupFailures.push(failure); + continue; + } + return { archivedThreadKeys, mutationFailure: failure, followupFailures }; + } + + return { archivedThreadKeys, mutationFailure: null, followupFailures }; +} + +export function buildMultiSelectThreadContextMenuItems(input: { + count: number; + hasRunningThread: boolean; +}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { + return [ + { id: "mark-unread", label: `Mark unread (${input.count})` }, + { + id: "archive", + label: `Archive (${input.count})`, + disabled: input.hasRunningThread, + }, + { id: "delete", label: `Delete (${input.count})`, destructive: true }, + ]; +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 715a94e9672..4b4b1e52a94 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -185,6 +185,8 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, @@ -1775,24 +1777,69 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; if (threadKeys.length === 0) return; const count = threadKeys.length; + const selectedThreadEntries = threadKeys.flatMap((threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + const thread = threadRef ? readThreadShell(threadRef) : null; + return threadRef && thread ? [{ threadKey, threadRef, thread }] : []; + }); + const hasRunningThread = selectedThreadEntries.some( + ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null, + ); const clicked = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], + buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }), position, ); if (clicked === "mark-unread") { - for (const threadKey of threadKeys) { - const thread = sidebarThreadByKeyRef.current.get(threadKey); - markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + for (const { threadKey, thread } of selectedThreadEntries) { + markThreadUnread(threadKey, thread.latestTurn?.completedAt); } clearSelection(); return; } + if (clicked === "archive") { + if (appSettingsConfirmThreadArchive) { + const confirmed = await api.dialogs.confirm( + `Archive ${count} thread${count === 1 ? "" : "s"}?`, + ); + if (!confirmed) return; + } + + const archiveOutcome = await archiveSelectedThreadEntries({ + entries: selectedThreadEntries, + archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }), + }); + for (const failure of archiveOutcome.followupFailures) { + if (isAtomCommandInterrupted(failure)) continue; + const error = squashAtomCommandFailure(failure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread archived, but navigation failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + if (archiveOutcome.mutationFailure) { + removeFromSelection(archiveOutcome.archivedThreadKeys); + if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) { + const error = squashAtomCommandFailure(archiveOutcome.mutationFailure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to archive threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + removeFromSelection(threadKeys); + return; + } + if (clicked !== "delete") return; if (appSettingsConfirmThreadDelete) { @@ -1806,10 +1853,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } const deletedThreadKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = sidebarThreadByKeyRef.current.get(threadKey); - if (!thread) continue; - const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + for (const { threadRef } of selectedThreadEntries) { + const result = await deleteThread(threadRef, { deletedThreadKeys, }); if (result._tag === "Failure") { @@ -1829,7 +1874,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec removeFromSelection(threadKeys); }, [ + appSettingsConfirmThreadArchive, appSettingsConfirmThreadDelete, + archiveThread, clearSelection, deleteThread, markThreadUnread, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d7..dbde5acce99 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -89,7 +89,7 @@ export function useThreadActions() { }, [router]); const archiveThread = useCallback( - async (target: ScopedThreadRef) => { + async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { const resolved = resolveThreadTarget(target); if (!resolved) return AsyncResult.success(undefined); const { thread, threadRef } = resolved; @@ -115,6 +115,8 @@ export function useThreadActions() { if (archiveResult._tag === "Failure") { return archiveResult; } + refreshArchivedThreadsForEnvironment(threadRef.environmentId); + opts.onArchived?.(); if (shouldNavigateToDraft) { const navigationResult = await settlePromise(() => @@ -123,11 +125,9 @@ export function useThreadActions() { if (navigationResult._tag === "Failure") { return navigationResult; } - refreshArchivedThreadsForEnvironment(threadRef.environmentId); return archiveResult; } - refreshArchivedThreadsForEnvironment(threadRef.environmentId); return archiveResult; }, [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], From 5fcfe242c1643eb5cee3699b160f59ad5f969fca Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Mon, 20 Jul 2026 04:53:02 -0400 Subject: [PATCH 010/110] fix(cli): support force removing projects (#3922) --- apps/server/src/bin.test.ts | 66 +++++++++++++++++++++++++++++++++- apps/server/src/cli/project.ts | 5 +++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 16b7a7f715a..999547b71d8 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -6,10 +6,16 @@ import * as NodePath from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentOrchestrationHttpApi, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServer from "effect/unstable/http/HttpServer"; @@ -22,6 +28,7 @@ import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; import * as ServerConfig from "./config.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; @@ -460,6 +467,63 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); + it.effect("force removes projects that still contain threads", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-force-remove-test-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-force-remove-workspace-"), + ); + + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const afterAdd = yield* readPersistedSnapshot(baseDir); + const project = afterAdd.projects.find( + (candidate) => candidate.workspaceRoot === workspaceRoot && candidate.deletedAt === null, + ); + assert.isTrue(project !== undefined); + + const config = yield* makeCliTestServerConfig(baseDir); + yield* Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-cli-force-remove-thread"), + threadId: ThreadId.make("thread-cli-force-remove"), + projectId: project!.id, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); + + yield* runCliWithRuntime([ + "project", + "remove", + project!.id, + "--force", + "--base-dir", + baseDir, + ]); + const afterRemove = yield* readPersistedSnapshot(baseDir); + assert.isTrue( + (afterRemove.projects.find((candidate) => candidate.id === project!.id)?.deletedAt ?? + null) !== null, + ); + assert.isTrue( + (afterRemove.threads.find((thread) => thread.id === "thread-cli-force-remove")?.deletedAt ?? + null) !== null, + ); + }), + ); + it.effect("routes project commands through a running server when runtime state is present", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync( diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 710d39c4c29..25733a5e35b 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -493,6 +493,10 @@ const projectRemoveCommand = Command.make("remove", { project: Argument.string("project").pipe( Argument.withDescription("Project id or workspace root to remove."), ), + force: Flag.boolean("force").pipe( + Flag.withDescription("Delete the project and all of its threads."), + Flag.withDefault(false), + ), }).pipe( Command.withDescription("Remove a project."), Command.withHandler((flags) => @@ -515,6 +519,7 @@ const projectRemoveCommand = Command.make("remove", { type: "project.delete", commandId: CommandId.make(yield* projectCommandUuid), projectId: project.id, + force: flags.force, }); return `Removed project ${project.id} (${project.title}).`; }), From d266c068d0e8b8cd168d38cd70b885a7a9e233e2 Mon Sep 17 00:00:00 2001 From: Shoaib Date: Mon, 20 Jul 2026 14:28:20 +0530 Subject: [PATCH 011/110] fix: allow sidebar to be shrunk when wider than viewport (#2456) Co-authored-by: Shoaib Ansari Co-authored-by: Julius Marminge --- apps/web/src/components/AppSidebarLayout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 4d0486aa866..ee56858bc58 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -138,7 +138,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { className="border-r border-border bg-card text-foreground" resizable={{ minWidth: THREAD_SIDEBAR_MIN_WIDTH, - shouldAcceptWidth: ({ nextWidth, wrapper }) => + shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => + nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, }} From 33f1cb422461bf0f072b9f181734e9b654d1ca69 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:58:38 +0100 Subject: [PATCH 012/110] fix(codex): show web search query and url in tool call details (#2093) Co-authored-by: Julius Marminge --- apps/server/src/provider/Layers/CodexAdapter.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 270126e934b..4cbaf299429 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -267,8 +267,14 @@ function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): stri } } -function itemDetail(item: CodexLifecycleItem): string | undefined { +function itemDetail(itemType: CanonicalItemType, item: CodexLifecycleItem): string | undefined { + const itemRecord = item as Record; + const action = itemRecord.action as Record | undefined; + const actionQueries = Array.isArray(action?.queries) ? action.queries : []; const candidates = [ + ...(itemType === "web_search" + ? [itemRecord.query, action?.query, ...actionQueries, action?.pattern, action?.url] + : []), "command" in item ? item.command : undefined, "title" in item ? item.title : undefined, "summary" in item ? item.summary : undefined, @@ -276,6 +282,7 @@ function itemDetail(item: CodexLifecycleItem): string | undefined { "path" in item ? item.path : undefined, "prompt" in item ? item.prompt : undefined, ]; + for (const candidate of candidates) { const trimmed = typeof candidate === "string" ? trimText(candidate) : undefined; if (!trimmed) continue; @@ -465,7 +472,7 @@ function mapItemLifecycle( return undefined; } - const detail = itemDetail(item); + const detail = itemDetail(itemType, item); const status = lifecycle === "item.started" ? "inProgress" @@ -839,7 +846,7 @@ function mapToRuntimeEvents( } const itemType = toCanonicalItemType(item.type); if (itemType === "plan") { - const detail = itemDetail(item); + const detail = itemDetail(itemType, item); if (!detail) { return []; } From 40c0ab088308fb010a3db488c545f10aa7179ed2 Mon Sep 17 00:00:00 2001 From: James <105842516+jamesx0416@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:04:03 +1000 Subject: [PATCH 013/110] Add Codex launch arguments setting (#2892) Co-authored-by: Julius Marminge Co-authored-by: Julius Marminge Co-authored-by: root --- .../src/provider/Layers/CodexAdapter.test.ts | 64 +++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 2 + .../src/provider/Layers/CodexProvider.ts | 16 +++-- .../Layers/CodexSessionRuntime.test.ts | 28 ++++++++ .../provider/Layers/CodexSessionRuntime.ts | 12 ++-- .../ProviderInstanceRegistryLive.test.ts | 1 + .../provider/Layers/ProviderRegistry.test.ts | 16 +++++ .../provider/Layers/codexLaunchArgs.test.ts | 59 +++++++++++++++ .../src/provider/Layers/codexLaunchArgs.ts | 48 +++++++++++++ apps/server/src/serverSettings.test.ts | 2 + .../CodexTextGeneration.test.ts | 72 ++++++++++++++++++- .../src/textGeneration/CodexTextGeneration.ts | 3 + .../settings/ProviderSettingsForm.test.ts | 1 + packages/contracts/src/settings.test.ts | 4 ++ packages/contracts/src/settings.ts | 10 ++- packages/shared/src/cliArgs.test.ts | 30 +++++++- packages/shared/src/cliArgs.ts | 62 +++++++++++++++- 17 files changed, 414 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/provider/Layers/codexLaunchArgs.test.ts create mode 100644 apps/server/src/provider/Layers/codexLaunchArgs.ts diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 515a7c6fcbb..4ae654a5187 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -279,6 +279,7 @@ validationLayer("CodexAdapterLive validation", (it) => { NodeAssert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], { binaryPath: "codex", cwd: process.cwd(), + launchArgs: "", model: "gpt-5.3-codex", providerInstanceId: ProviderInstanceId.make("codex"), serviceTier: "priority", @@ -359,6 +360,69 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("passes configured launch args into the session runtime", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-launch-args"), + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.launchArgs, "--strict-config --enable foo"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for the session runtime", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({ launchArgs: "--enable settings-feature" }); + return yield* makeCodexAdapter(codexConfig, { + environment: { T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --enable env-feature " }, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-launch-args-env"), + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.launchArgs, "--strict-config --enable env-feature"); + }).pipe(Effect.provide(layer)); + }); + it.effect("maps codex model options for the adapter's bound custom instance id", () => { const customInstanceId = ProviderInstanceId.make("codex_personal"); const customRuntimeFactory = makeRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 4cbaf299429..38a5887cdc3 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -61,6 +61,7 @@ import { type CodexSessionRuntimeShape, } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); const isCodexSessionRuntimeThreadIdMissingError = Schema.is( @@ -1399,6 +1400,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), binaryPath: codexConfig.binaryPath, + launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), ...(options?.environment ? { environment: options.environment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index a2182cfb73c..9306087a0bc 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -26,6 +26,7 @@ import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, @@ -289,6 +290,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly cwd: string; readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; @@ -303,10 +305,14 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ...input.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; - const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { - env: environment, - extendEnv: true, - }); + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + codexAppServerArgs(input.launchArgs), + { + env: environment, + extendEnv: true, + }, + ); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { @@ -465,6 +471,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu probe: (input: { readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly cwd: string; readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; @@ -503,6 +510,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, + launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), cwd: process.cwd(), customModels: codexSettings.customModels, environment: resolvedEnvironment, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 1527072dae7..119fa36303a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -13,6 +13,7 @@ import { CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; +import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, hasConfiguredMcpServer, @@ -289,6 +290,33 @@ describe("hasConfiguredMcpServer", () => { }); }); +describe("codexSessionAppServerArgs", () => { + it("keeps the app-server subcommand when explicit args are provided", () => { + NodeAssert.deepStrictEqual(codexSessionAppServerArgs(["-c", "model=gpt-5"], undefined), [ + "app-server", + "-c", + "model=gpt-5", + ]); + }); + + it("keeps launch args when explicit app-server args are provided", () => { + NodeAssert.deepStrictEqual( + codexSessionAppServerArgs( + ["-c", "mcp_servers.t3-code.url=http://127.0.0.1/mcp"], + "--strict-config --enable foo", + ), + [ + "app-server", + "--strict-config", + "--enable", + "foo", + "-c", + "mcp_servers.t3-code.url=http://127.0.0.1/mcp", + ], + ); + }); +}); + describe("isRecoverableThreadResumeError", () => { it("matches missing thread errors", () => { NodeAssert.equal( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 91938b5355d..5a81e915e34 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -36,6 +36,7 @@ import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); @@ -97,6 +98,7 @@ export interface CodexSessionRuntimeOptions { readonly providerInstanceId?: ProviderInstanceId; readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly environment?: NodeJS.ProcessEnv; readonly cwd: string; readonly runtimeMode: RuntimeMode; @@ -717,11 +719,11 @@ export const makeCodexSessionRuntime = ( ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; const extendEnv = options.environment === undefined; - const spawnCommand = yield* resolveSpawnCommand( - options.binaryPath, - ["app-server", ...(options.appServerArgs ?? [])], - { env, extendEnv }, - ); + const appServerArgs = codexSessionAppServerArgs(options.appServerArgs, options.launchArgs); + const spawnCommand = yield* resolveSpawnCommand(options.binaryPath, appServerArgs, { + env, + extendEnv, + }); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 73390450efa..384de852f9b 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -61,6 +61,7 @@ const makeCodexConfig = (overrides: Partial): CodexSettings => ({ binaryPath: "codex", homePath: "", shadowHomePath: "", + launchArgs: "", customModels: [], ...overrides, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 78ff5cf493d..159d853121c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -55,6 +55,7 @@ const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTING const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); +const decodeCodexSettings = Schema.decodeSync(CodexSettings); const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ enabled: false, }); @@ -348,6 +349,21 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("passes configured launch args to the Codex provider probe", () => + Effect.gen(function* () { + let observedLaunchArgs: string | undefined; + const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); + + const status = yield* checkCodexProviderStatus(settings, (input) => { + observedLaunchArgs = input.launchArgs; + return Effect.succeed(makeCodexProbeSnapshot()); + }); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); + }), + ); + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.test.ts b/apps/server/src/provider/Layers/codexLaunchArgs.test.ts new file mode 100644 index 00000000000..115ac28eaf9 --- /dev/null +++ b/apps/server/src/provider/Layers/codexLaunchArgs.test.ts @@ -0,0 +1,59 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { + codexAppServerArgs, + codexExecLaunchArgs, + resolveCodexLaunchArgs, +} from "./codexLaunchArgs.ts"; + +describe("resolveCodexLaunchArgs", () => { + it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => { + NodeAssert.equal( + resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }), + "--enable foo", + ); + }); + + it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => { + NodeAssert.equal( + resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }), + "--strict-config", + ); + }); + + it("ignores whitespace-only environment values", () => { + NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), ""); + }); +}); + +describe("codexAppServerArgs", () => { + it("returns the app-server command for empty launch args", () => { + NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]); + }); + + it("appends parsed launch args after app-server", () => { + NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [ + "app-server", + "--strict-config", + "--enable", + "foo", + ]); + }); +}); + +describe("codexExecLaunchArgs", () => { + it("keeps shared codex flags and omits app-server-only flags", () => { + NodeAssert.deepStrictEqual( + codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'), + ["--strict-config", "--enable", "foo", "--config", "model=gpt 5"], + ); + }); + + it("does not pair value-taking flags with adjacent flags", () => { + NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [ + "--strict-config", + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.ts b/apps/server/src/provider/Layers/codexLaunchArgs.ts new file mode 100644 index 00000000000..771a4f0b6ed --- /dev/null +++ b/apps/server/src/provider/Layers/codexLaunchArgs.ts @@ -0,0 +1,48 @@ +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; + +export const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; + +export const resolveCodexLaunchArgs = ( + launchArgs?: string, + environment: NodeJS.ProcessEnv = process.env, +) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; + +export const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => + tokenizeCliArgs(launchArgs); + +export const codexAppServerArgs = (launchArgs?: string) => [ + "app-server", + ...codexLaunchArgv(launchArgs), +]; + +export const codexExecLaunchArgs = (launchArgs?: string) => { + const args = codexLaunchArgv(launchArgs); + const execArgs: Array = []; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === undefined) continue; + + if (arg === "--strict-config" || arg.startsWith("--config=") || arg.startsWith("-c=")) { + execArgs.push(arg); + } else if (arg === "--config" || arg === "-c" || arg === "--enable" || arg === "--disable") { + const value = args[index + 1]; + if (value !== undefined && !value.startsWith("-")) { + execArgs.push(arg, value); + index++; + } + } else if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) { + execArgs.push(arg); + } + } + + return execArgs; +}; + +export const codexSessionAppServerArgs = ( + appServerArgs: ReadonlyArray | undefined, + launchArgs: string | undefined, +) => { + const launchAppServerArgs = codexAppServerArgs(launchArgs); + return appServerArgs ? [...launchAppServerArgs, ...appServerArgs] : launchAppServerArgs; +}; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18de..487ae9b45b8 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -179,6 +179,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", homePath: "/Users/julius/.codex", shadowHomePath: "", + launchArgs: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { @@ -420,6 +421,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", homePath: "", shadowHomePath: "", + launchArgs: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 24054a95870..657118fff51 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -35,6 +35,8 @@ function makeFakeCodexBinary( requireServiceTier?: string; requireReasoningEffort?: string; forbidReasoningEffort?: boolean; + requireArg?: string; + forbidArg?: string; stdinMustContain?: string; stdinMustNotContain?: string; }, @@ -50,6 +52,7 @@ function makeFakeCodexBinary( codexPath, [ "#!/bin/sh", + 'original_args="$*"', 'output_path=""', 'seen_image="0"', 'seen_service_tier=""', @@ -87,6 +90,22 @@ function makeFakeCodexBinary( " shift", "done", 'stdin_content="$(cat)"', + ...(input.requireArg !== undefined + ? [ + `case " $original_args " in *" ${input.requireArg} "*) ;; *)`, + ` printf "%s\\n" "missing arg: ${input.requireArg}" >&2`, + ` exit 8`, + "esac", + ] + : []), + ...(input.forbidArg !== undefined + ? [ + `case " $original_args " in *" ${input.forbidArg} "*)`, + ` printf "%s\\n" "forbidden arg: ${input.forbidArg}" >&2`, + ` exit 9`, + "esac", + ] + : []), ...(input.requireImage ? [ 'if [ "$seen_image" != "1" ]; then', @@ -166,8 +185,12 @@ function withFakeCodexEnv( requireServiceTier?: string; requireReasoningEffort?: string; forbidReasoningEffort?: boolean; + requireArg?: string; + forbidArg?: string; stdinMustContain?: string; stdinMustNotContain?: string; + launchArgs?: string; + environment?: NodeJS.ProcessEnv; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { @@ -175,8 +198,8 @@ function withFakeCodexEnv( const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); const codexPath = yield* makeFakeCodexBinary(tempDir, input); - const config = decodeCodexSettings({ binaryPath: codexPath }); - const textGeneration = yield* makeCodexTextGeneration(config); + const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs }); + const textGeneration = yield* makeCodexTextGeneration(config, input.environment); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -237,6 +260,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); + it.effect("passes exec-safe launch args into codex exec", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + launchArgs: "--strict-config --listen off", + requireArg: "--strict-config", + forbidArg: "--listen", + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ), + ); + + it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for codex exec over settings", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + launchArgs: "--enable settings-feature", + environment: { T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --listen off " }, + requireArg: "--strict-config", + forbidArg: "settings-feature", + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ), + ); + it.effect("defaults git text generation codex effort to low", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0e68994fd3d..6a5c0df43c7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -14,6 +14,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; +import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts"; import { TextGenerationError } from "@t3tools/contracts"; import * as TextGeneration from "./TextGeneration.ts"; import { @@ -174,6 +175,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const outputPath = yield* writeTempFile(operation, "codex-output", ""); const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { + const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment); const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; @@ -182,6 +184,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func codexConfig.binaryPath || "codex", [ "exec", + ...codexExecLaunchArgs(launchArgs), "--ephemeral", "--skip-git-repo-check", "-s", diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 33331c14901..ea8712a87eb 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -18,6 +18,7 @@ describe("ProviderSettingsForm helpers", () => { "binaryPath", "homePath", "shadowHomePath", + "launchArgs", ]); }); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca336..0f618729c43 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -141,6 +141,7 @@ describe("ServerSettingsPatch string normalization", () => { codex: { binaryPath: " /opt/homebrew/bin/codex ", homePath: " ~/.codex ", + launchArgs: " --strict-config --enable foo ", }, }, providerInstances: { @@ -157,6 +158,7 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(patch.providers?.codex?.homePath).toBe("~/.codex"); + expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo"); expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( "codex", ); @@ -178,11 +180,13 @@ describe("ServerSettingsPatch string normalization", () => { codex: { ...defaultSettings.providers.codex, binaryPath: " /opt/homebrew/bin/codex ", + launchArgs: " --strict-config ", }, }, }); expect(encoded.addProjectBaseDirectory).toBe("~/Development"); expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); + expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config"); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b05f397bf5c..fe593f49199 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -191,13 +191,20 @@ export const CodexSettings = makeProviderSettingsSchema( }, }), ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed to codex app-server on session start.", + }), + ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), }, { - order: ["binaryPath", "homePath", "shadowHomePath"], + order: ["binaryPath", "homePath", "shadowHomePath", "launchArgs"], }, ); export type CodexSettings = typeof CodexSettings.Type; @@ -469,6 +476,7 @@ const CodexSettingsPatch = Schema.Struct({ binaryPath: Schema.optionalKey(TrimmedString), homePath: Schema.optionalKey(TrimmedString), shadowHomePath: Schema.optionalKey(TrimmedString), + launchArgs: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts index adf2a3f03c0..1fc6ab0215e 100644 --- a/packages/shared/src/cliArgs.test.ts +++ b/packages/shared/src/cliArgs.test.ts @@ -1,6 +1,27 @@ import { describe, expect, it } from "vite-plus/test"; -import { parseCliArgs } from "./cliArgs.ts"; +import { parseCliArgs, tokenizeCliArgs } from "./cliArgs.ts"; + +describe("tokenizeCliArgs", () => { + it("preserves quoted values and escaped spaces", () => { + expect( + tokenizeCliArgs( + String.raw`--config model="gpt 5" --enable foo\ bar --config=profile='work profile'`, + ), + ).toEqual(["--config", "model=gpt 5", "--enable", "foo bar", "--config=profile=work profile"]); + }); + + it("preserves literal backslashes in path values", () => { + expect( + tokenizeCliArgs(String.raw`--config cacheDir=C:\Users\me --config "quoted=C:\Users\me"`), + ).toEqual([ + "--config", + String.raw`cacheDir=C:\Users\me`, + "--config", + String.raw`quoted=C:\Users\me`, + ]); + }); +}); describe("parseCliArgs", () => { it("returns empty result for empty string", () => { @@ -57,6 +78,13 @@ describe("parseCliArgs", () => { }); }); + it("parses quoted --append-system-prompt with value and --chrome", () => { + expect(parseCliArgs(`--append-system-prompt "always think step by step" --chrome`)).toEqual({ + flags: { "append-system-prompt": "always think step by step", chrome: null }, + positionals: [], + }); + }); + it("parses --max-budget-usd with numeric value", () => { expect(parseCliArgs("--chrome --max-budget-usd 5.00")).toEqual({ flags: { chrome: null, "max-budget-usd": "5.00" }, diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts index 20920093302..69851f2f3fb 100644 --- a/packages/shared/src/cliArgs.ts +++ b/packages/shared/src/cliArgs.ts @@ -7,10 +7,67 @@ export interface ParseCliArgsOptions { readonly booleanFlags?: readonly string[]; } +export function tokenizeCliArgs(args?: string): ReadonlyArray { + const input = args?.trim(); + if (!input) return []; + + const tokens: string[] = []; + let current = ""; + let quote: "'" | '"' | undefined; + let quoted = false; + + for (let index = 0; index < input.length; index++) { + const char = input[index]; + if (char === undefined) continue; + + if (quote) { + if (char === quote) { + quote = undefined; + quoted = true; + } else if (char === "\\" && quote === '"') { + const next = input[index + 1]; + if (next !== undefined && ['"', "\\", "$", "`"].includes(next)) { + current += next; + index++; + } else { + current += char; + } + } else { + current += char; + } + continue; + } + + if (char === "'" || char === '"') { + quote = char; + quoted = true; + } else if (/\s/.test(char)) { + if (current || quoted) { + tokens.push(current); + current = ""; + quoted = false; + } + } else if (char === "\\") { + const next = input[index + 1]; + if (next !== undefined && /\s/.test(next)) { + current += next; + index++; + } else { + current += char; + } + } else { + current += char; + } + } + + if (current || quoted) tokens.push(current); + return tokens; +} + /** * Parse CLI-style arguments into flags and positionals. * - * Accepts a string (split by whitespace) or a pre-split argv array. + * Accepts a string (quote-aware tokenized) or a pre-split argv array. * Supports `--key value`, `--key=value`, and `--flag` (boolean) syntax. * * parseCliArgs("") @@ -32,8 +89,7 @@ export function parseCliArgs( args: string | readonly string[], options?: ParseCliArgsOptions, ): ParsedCliArgs { - const tokens = - typeof args === "string" ? args.trim().split(/\s+/).filter(Boolean) : Array.from(args); + const tokens = typeof args === "string" ? tokenizeCliArgs(args) : Array.from(args); const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined; const flags: Record = {}; From 501ce27b81826960dcbb9a93a6b3407efcf09366 Mon Sep 17 00:00:00 2001 From: Andrew Forster <76947376+Andrew-Forster@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:04:48 -0700 Subject: [PATCH 014/110] [orchestration] Clear stale active turn when session becomes inactive (#3159) Co-authored-by: Julius Marminge --- .../Layers/ProviderRuntimeIngestion.test.ts | 47 +++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 20 +++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 72976768fec..20611e1ee75 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -448,6 +448,51 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBeNull(); }); + it("clears active turn when provider session becomes ready", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-session-ready"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-session-ready"), + }); + + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-session-ready", + 10_000, + ); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-state-ready-with-active-turn"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:01.000Z", + payload: { + state: "ready", + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.session?.status === "ready" && + entry.session?.activeTurnId === null && + entry.session?.lastError === null, + 10_000, + ); + expect(thread.session?.status).toBe("ready"); + expect(thread.session?.activeTurnId).toBeNull(); + expect(thread.session?.lastError).toBeNull(); + }); + it("does not clear active turn when session/thread started arrives mid-turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -466,6 +511,7 @@ describe("ProviderRuntimeIngestion", () => { (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-midturn-lifecycle", + 10_000, ); harness.emit({ @@ -502,6 +548,7 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread( harness.readModel, (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + 10_000, ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index ef3454348e4..a5ec9d2b857 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -283,6 +283,12 @@ function orchestrationSessionStatusFromRuntimeState( } } +function sessionStatusAllowsActiveTurn( + status: ReturnType, +): boolean { + return status === "starting" || status === "running"; +} + function requestKindFromCanonicalRequestType( requestType: string | undefined, ): "command" | "file-read" | "file-change" | undefined { @@ -1362,12 +1368,6 @@ const make = Effect.gen(function* () { event.type === "turn.started" || event.type === "turn.completed" ) { - const nextActiveTurnId = - event.type === "turn.started" - ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" - ? null - : activeTurnId; const status = (() => { switch (event.type) { case "session.state.changed": @@ -1387,6 +1387,14 @@ const make = Effect.gen(function* () { return activeTurnId !== null ? "running" : "ready"; } })(); + const nextActiveTurnId = + event.type === "turn.started" + ? (eventTurnId ?? null) + : event.type === "turn.completed" || event.type === "session.exited" + ? null + : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn(status) + ? null + : activeTurnId; const lastError = event.type === "session.state.changed" && event.payload.state === "error" ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") From 08993a5ec8fbe5c55a2a4fa8f5fb37f870e87cf6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 11:14:19 +0200 Subject: [PATCH 015/110] Regenerate Codex reset credit protocol bindings (#4173) Co-authored-by: codex --- .../scripts/generate.ts | 4 +- .../src/_generated/meta.gen.ts | 44 +- .../src/_generated/namespaces.gen.ts | 17 +- .../src/_generated/schema.gen.ts | 7141 ++++++++++++++--- .../src/protocol.test.ts | 86 + 5 files changed, 5973 insertions(+), 1319 deletions(-) diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 3deb4514292..9f23a144524 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -17,7 +17,7 @@ import { } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -const UPSTREAM_REF = "b39f943a634a6e7ba86c3d6e8cf6d5f35e612566"; +const UPSTREAM_REF = "678157acaa819d5510adfe359abb5d0392cfe461"; const USER_AGENT = "effect-codex-app-server-generator"; const GITHUB_API_BASE = "https://api.github.com/repos/openai/codex/contents/codex-rs/app-server-protocol"; @@ -349,10 +349,12 @@ function resolveResponseTypeName( "account/logout": "LogoutAccountResponse", "account/rateLimits/read": "GetAccountRateLimitsResponse", "account/usage/read": "GetAccountTokenUsageResponse", + "account/workspaceMessages/read": "GetWorkspaceMessagesResponse", "config/batchWrite": "ConfigWriteResponse", "config/mcpServer/reload": "McpServerRefreshResponse", "config/value/write": "ConfigWriteResponse", "configRequirements/read": "ConfigRequirementsReadResponse", + "externalAgentConfig/import/readHistories": "ExternalAgentConfigImportHistoriesReadResponse", }; const override = overrides[method]; diff --git a/packages/effect-codex-app-server/src/_generated/meta.gen.ts b/packages/effect-codex-app-server/src/_generated/meta.gen.ts index ed39896bdb9..24452e881f6 100644 --- a/packages/effect-codex-app-server/src/_generated/meta.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/meta.gen.ts @@ -1,5 +1,5 @@ // This file is generated by the effect-codex-app-server package. Do not edit manually. -// Upstream protocol ref: b39f943a634a6e7ba86c3d6e8cf6d5f35e612566 +// Upstream protocol ref: 678157acaa819d5510adfe359abb5d0392cfe461 import * as CodexSchema from "./schema.gen.ts"; @@ -40,7 +40,9 @@ export const CLIENT_REQUEST_METHODS = { "plugin/share/list": "plugin/share/list", "plugin/share/checkout": "plugin/share/checkout", "plugin/share/delete": "plugin/share/delete", + "app/read": "app/read", "app/list": "app/list", + "app/installed": "app/installed", "fs/readFile": "fs/readFile", "fs/writeFile": "fs/writeFile", "fs/createDirectory": "fs/createDirectory", @@ -73,7 +75,9 @@ export const CLIENT_REQUEST_METHODS = { "account/login/cancel": "account/login/cancel", "account/logout": "account/logout", "account/rateLimits/read": "account/rateLimits/read", + "account/rateLimitResetCredit/consume": "account/rateLimitResetCredit/consume", "account/usage/read": "account/usage/read", + "account/workspaceMessages/read": "account/workspaceMessages/read", "account/sendAddCreditsNudgeEmail": "account/sendAddCreditsNudgeEmail", "feedback/upload": "feedback/upload", "command/exec": "command/exec", @@ -83,6 +87,7 @@ export const CLIENT_REQUEST_METHODS = { "config/read": "config/read", "externalAgentConfig/detect": "externalAgentConfig/detect", "externalAgentConfig/import": "externalAgentConfig/import", + "externalAgentConfig/import/readHistories": "externalAgentConfig/import/readHistories", "config/value/write": "config/value/write", "config/batchWrite": "config/batchWrite", "configRequirements/read": "configRequirements/read", @@ -122,6 +127,8 @@ export const SERVER_NOTIFICATION_METHODS = { "thread/name/updated": "thread/name/updated", "thread/goal/updated": "thread/goal/updated", "thread/goal/cleared": "thread/goal/cleared", + "thread/environment/connected": "thread/environment/connected", + "thread/environment/disconnected": "thread/environment/disconnected", "thread/settings/updated": "thread/settings/updated", "thread/tokenUsage/updated": "thread/tokenUsage/updated", "turn/started": "turn/started", @@ -135,6 +142,7 @@ export const SERVER_NOTIFICATION_METHODS = { "item/autoApprovalReview/completed": "item/autoApprovalReview/completed", "item/completed": "item/completed", "rawResponseItem/completed": "rawResponseItem/completed", + "rawResponse/completed": "rawResponse/completed", "item/agentMessage/delta": "item/agentMessage/delta", "item/plan/delta": "item/plan/delta", "command/exec/outputDelta": "command/exec/outputDelta", @@ -152,6 +160,7 @@ export const SERVER_NOTIFICATION_METHODS = { "account/rateLimits/updated": "account/rateLimits/updated", "app/list/updated": "app/list/updated", "remoteControl/status/changed": "remoteControl/status/changed", + "externalAgentConfig/import/progress": "externalAgentConfig/import/progress", "externalAgentConfig/import/completed": "externalAgentConfig/import/completed", "fs/changed": "fs/changed", "item/reasoning/summaryTextDelta": "item/reasoning/summaryTextDelta", @@ -161,6 +170,7 @@ export const SERVER_NOTIFICATION_METHODS = { "model/rerouted": "model/rerouted", "model/verification": "model/verification", "turn/moderationMetadata": "turn/moderationMetadata", + "model/safetyBuffering/updated": "model/safetyBuffering/updated", warning: "warning", guardianWarning: "guardianWarning", deprecationNotice: "deprecationNotice", @@ -222,7 +232,9 @@ export interface ClientRequestParamsByMethod { readonly "plugin/share/list": CodexSchema.V2PluginShareListParams; readonly "plugin/share/checkout": CodexSchema.V2PluginShareCheckoutParams; readonly "plugin/share/delete": CodexSchema.V2PluginShareDeleteParams; + readonly "app/read": CodexSchema.V2AppsReadParams; readonly "app/list": CodexSchema.V2AppsListParams; + readonly "app/installed": CodexSchema.V2AppsInstalledParams; readonly "fs/readFile": CodexSchema.V2FsReadFileParams; readonly "fs/writeFile": CodexSchema.V2FsWriteFileParams; readonly "fs/createDirectory": CodexSchema.V2FsCreateDirectoryParams; @@ -255,7 +267,9 @@ export interface ClientRequestParamsByMethod { readonly "account/login/cancel": CodexSchema.V2CancelLoginAccountParams; readonly "account/logout": undefined; readonly "account/rateLimits/read": undefined; + readonly "account/rateLimitResetCredit/consume": CodexSchema.V2ConsumeAccountRateLimitResetCreditParams; readonly "account/usage/read": undefined; + readonly "account/workspaceMessages/read": undefined; readonly "account/sendAddCreditsNudgeEmail": CodexSchema.V2SendAddCreditsNudgeEmailParams; readonly "feedback/upload": CodexSchema.V2FeedbackUploadParams; readonly "command/exec": CodexSchema.V2CommandExecParams; @@ -265,6 +279,7 @@ export interface ClientRequestParamsByMethod { readonly "config/read": CodexSchema.V2ConfigReadParams; readonly "externalAgentConfig/detect": CodexSchema.V2ExternalAgentConfigDetectParams; readonly "externalAgentConfig/import": CodexSchema.V2ExternalAgentConfigImportParams; + readonly "externalAgentConfig/import/readHistories": undefined; readonly "config/value/write": CodexSchema.V2ConfigValueWriteParams; readonly "config/batchWrite": CodexSchema.V2ConfigBatchWriteParams; readonly "configRequirements/read": undefined; @@ -312,7 +327,9 @@ export interface ClientRequestResponsesByMethod { readonly "plugin/share/list": CodexSchema.V2PluginShareListResponse; readonly "plugin/share/checkout": CodexSchema.V2PluginShareCheckoutResponse; readonly "plugin/share/delete": CodexSchema.V2PluginShareDeleteResponse; + readonly "app/read": CodexSchema.V2AppsReadResponse; readonly "app/list": CodexSchema.V2AppsListResponse; + readonly "app/installed": CodexSchema.V2AppsInstalledResponse; readonly "fs/readFile": CodexSchema.V2FsReadFileResponse; readonly "fs/writeFile": CodexSchema.V2FsWriteFileResponse; readonly "fs/createDirectory": CodexSchema.V2FsCreateDirectoryResponse; @@ -345,7 +362,9 @@ export interface ClientRequestResponsesByMethod { readonly "account/login/cancel": CodexSchema.V2CancelLoginAccountResponse; readonly "account/logout": CodexSchema.V2LogoutAccountResponse; readonly "account/rateLimits/read": CodexSchema.V2GetAccountRateLimitsResponse; + readonly "account/rateLimitResetCredit/consume": CodexSchema.V2ConsumeAccountRateLimitResetCreditResponse; readonly "account/usage/read": CodexSchema.V2GetAccountTokenUsageResponse; + readonly "account/workspaceMessages/read": CodexSchema.V2GetWorkspaceMessagesResponse; readonly "account/sendAddCreditsNudgeEmail": CodexSchema.V2SendAddCreditsNudgeEmailResponse; readonly "feedback/upload": CodexSchema.V2FeedbackUploadResponse; readonly "command/exec": CodexSchema.V2CommandExecResponse; @@ -355,6 +374,7 @@ export interface ClientRequestResponsesByMethod { readonly "config/read": CodexSchema.V2ConfigReadResponse; readonly "externalAgentConfig/detect": CodexSchema.V2ExternalAgentConfigDetectResponse; readonly "externalAgentConfig/import": CodexSchema.V2ExternalAgentConfigImportResponse; + readonly "externalAgentConfig/import/readHistories": CodexSchema.V2ExternalAgentConfigImportHistoriesReadResponse; readonly "config/value/write": CodexSchema.V2ConfigWriteResponse; readonly "config/batchWrite": CodexSchema.V2ConfigWriteResponse; readonly "configRequirements/read": CodexSchema.V2ConfigRequirementsReadResponse; @@ -407,6 +427,8 @@ export interface ServerNotificationParamsByMethod { readonly "thread/name/updated": CodexSchema.V2ThreadNameUpdatedNotification; readonly "thread/goal/updated": CodexSchema.V2ThreadGoalUpdatedNotification; readonly "thread/goal/cleared": CodexSchema.V2ThreadGoalClearedNotification; + readonly "thread/environment/connected": CodexSchema.V2EnvironmentConnectionNotification; + readonly "thread/environment/disconnected": CodexSchema.V2EnvironmentConnectionNotification; readonly "thread/settings/updated": CodexSchema.V2ThreadSettingsUpdatedNotification; readonly "thread/tokenUsage/updated": CodexSchema.V2ThreadTokenUsageUpdatedNotification; readonly "turn/started": CodexSchema.V2TurnStartedNotification; @@ -420,6 +442,7 @@ export interface ServerNotificationParamsByMethod { readonly "item/autoApprovalReview/completed": CodexSchema.V2ItemGuardianApprovalReviewCompletedNotification; readonly "item/completed": CodexSchema.V2ItemCompletedNotification; readonly "rawResponseItem/completed": CodexSchema.V2RawResponseItemCompletedNotification; + readonly "rawResponse/completed": CodexSchema.V2RawResponseCompletedNotification; readonly "item/agentMessage/delta": CodexSchema.V2AgentMessageDeltaNotification; readonly "item/plan/delta": CodexSchema.V2PlanDeltaNotification; readonly "command/exec/outputDelta": CodexSchema.V2CommandExecOutputDeltaNotification; @@ -437,6 +460,7 @@ export interface ServerNotificationParamsByMethod { readonly "account/rateLimits/updated": CodexSchema.V2AccountRateLimitsUpdatedNotification; readonly "app/list/updated": CodexSchema.V2AppListUpdatedNotification; readonly "remoteControl/status/changed": CodexSchema.V2RemoteControlStatusChangedNotification; + readonly "externalAgentConfig/import/progress": CodexSchema.V2ExternalAgentConfigImportProgressNotification; readonly "externalAgentConfig/import/completed": CodexSchema.V2ExternalAgentConfigImportCompletedNotification; readonly "fs/changed": CodexSchema.V2FsChangedNotification; readonly "item/reasoning/summaryTextDelta": CodexSchema.V2ReasoningSummaryTextDeltaNotification; @@ -446,6 +470,7 @@ export interface ServerNotificationParamsByMethod { readonly "model/rerouted": CodexSchema.V2ModelReroutedNotification; readonly "model/verification": CodexSchema.V2ModelVerificationNotification; readonly "turn/moderationMetadata": CodexSchema.V2TurnModerationMetadataNotification; + readonly "model/safetyBuffering/updated": CodexSchema.V2ModelSafetyBufferingUpdatedNotification; readonly warning: CodexSchema.V2WarningNotification; readonly guardianWarning: CodexSchema.V2GuardianWarningNotification; readonly deprecationNotice: CodexSchema.V2DeprecationNoticeNotification; @@ -502,7 +527,9 @@ export const CLIENT_REQUEST_PARAMS = { "plugin/share/list": CodexSchema.V2PluginShareListParams, "plugin/share/checkout": CodexSchema.V2PluginShareCheckoutParams, "plugin/share/delete": CodexSchema.V2PluginShareDeleteParams, + "app/read": CodexSchema.V2AppsReadParams, "app/list": CodexSchema.V2AppsListParams, + "app/installed": CodexSchema.V2AppsInstalledParams, "fs/readFile": CodexSchema.V2FsReadFileParams, "fs/writeFile": CodexSchema.V2FsWriteFileParams, "fs/createDirectory": CodexSchema.V2FsCreateDirectoryParams, @@ -535,7 +562,9 @@ export const CLIENT_REQUEST_PARAMS = { "account/login/cancel": CodexSchema.V2CancelLoginAccountParams, "account/logout": undefined, "account/rateLimits/read": undefined, + "account/rateLimitResetCredit/consume": CodexSchema.V2ConsumeAccountRateLimitResetCreditParams, "account/usage/read": undefined, + "account/workspaceMessages/read": undefined, "account/sendAddCreditsNudgeEmail": CodexSchema.V2SendAddCreditsNudgeEmailParams, "feedback/upload": CodexSchema.V2FeedbackUploadParams, "command/exec": CodexSchema.V2CommandExecParams, @@ -545,6 +574,7 @@ export const CLIENT_REQUEST_PARAMS = { "config/read": CodexSchema.V2ConfigReadParams, "externalAgentConfig/detect": CodexSchema.V2ExternalAgentConfigDetectParams, "externalAgentConfig/import": CodexSchema.V2ExternalAgentConfigImportParams, + "externalAgentConfig/import/readHistories": undefined, "config/value/write": CodexSchema.V2ConfigValueWriteParams, "config/batchWrite": CodexSchema.V2ConfigBatchWriteParams, "configRequirements/read": undefined, @@ -592,7 +622,9 @@ export const CLIENT_REQUEST_RESPONSES = { "plugin/share/list": CodexSchema.V2PluginShareListResponse, "plugin/share/checkout": CodexSchema.V2PluginShareCheckoutResponse, "plugin/share/delete": CodexSchema.V2PluginShareDeleteResponse, + "app/read": CodexSchema.V2AppsReadResponse, "app/list": CodexSchema.V2AppsListResponse, + "app/installed": CodexSchema.V2AppsInstalledResponse, "fs/readFile": CodexSchema.V2FsReadFileResponse, "fs/writeFile": CodexSchema.V2FsWriteFileResponse, "fs/createDirectory": CodexSchema.V2FsCreateDirectoryResponse, @@ -625,7 +657,9 @@ export const CLIENT_REQUEST_RESPONSES = { "account/login/cancel": CodexSchema.V2CancelLoginAccountResponse, "account/logout": CodexSchema.V2LogoutAccountResponse, "account/rateLimits/read": CodexSchema.V2GetAccountRateLimitsResponse, + "account/rateLimitResetCredit/consume": CodexSchema.V2ConsumeAccountRateLimitResetCreditResponse, "account/usage/read": CodexSchema.V2GetAccountTokenUsageResponse, + "account/workspaceMessages/read": CodexSchema.V2GetWorkspaceMessagesResponse, "account/sendAddCreditsNudgeEmail": CodexSchema.V2SendAddCreditsNudgeEmailResponse, "feedback/upload": CodexSchema.V2FeedbackUploadResponse, "command/exec": CodexSchema.V2CommandExecResponse, @@ -635,6 +669,8 @@ export const CLIENT_REQUEST_RESPONSES = { "config/read": CodexSchema.V2ConfigReadResponse, "externalAgentConfig/detect": CodexSchema.V2ExternalAgentConfigDetectResponse, "externalAgentConfig/import": CodexSchema.V2ExternalAgentConfigImportResponse, + "externalAgentConfig/import/readHistories": + CodexSchema.V2ExternalAgentConfigImportHistoriesReadResponse, "config/value/write": CodexSchema.V2ConfigWriteResponse, "config/batchWrite": CodexSchema.V2ConfigWriteResponse, "configRequirements/read": CodexSchema.V2ConfigRequirementsReadResponse, @@ -687,6 +723,8 @@ export const SERVER_NOTIFICATION_PARAMS = { "thread/name/updated": CodexSchema.V2ThreadNameUpdatedNotification, "thread/goal/updated": CodexSchema.V2ThreadGoalUpdatedNotification, "thread/goal/cleared": CodexSchema.V2ThreadGoalClearedNotification, + "thread/environment/connected": CodexSchema.V2EnvironmentConnectionNotification, + "thread/environment/disconnected": CodexSchema.V2EnvironmentConnectionNotification, "thread/settings/updated": CodexSchema.V2ThreadSettingsUpdatedNotification, "thread/tokenUsage/updated": CodexSchema.V2ThreadTokenUsageUpdatedNotification, "turn/started": CodexSchema.V2TurnStartedNotification, @@ -701,6 +739,7 @@ export const SERVER_NOTIFICATION_PARAMS = { CodexSchema.V2ItemGuardianApprovalReviewCompletedNotification, "item/completed": CodexSchema.V2ItemCompletedNotification, "rawResponseItem/completed": CodexSchema.V2RawResponseItemCompletedNotification, + "rawResponse/completed": CodexSchema.V2RawResponseCompletedNotification, "item/agentMessage/delta": CodexSchema.V2AgentMessageDeltaNotification, "item/plan/delta": CodexSchema.V2PlanDeltaNotification, "command/exec/outputDelta": CodexSchema.V2CommandExecOutputDeltaNotification, @@ -718,6 +757,8 @@ export const SERVER_NOTIFICATION_PARAMS = { "account/rateLimits/updated": CodexSchema.V2AccountRateLimitsUpdatedNotification, "app/list/updated": CodexSchema.V2AppListUpdatedNotification, "remoteControl/status/changed": CodexSchema.V2RemoteControlStatusChangedNotification, + "externalAgentConfig/import/progress": + CodexSchema.V2ExternalAgentConfigImportProgressNotification, "externalAgentConfig/import/completed": CodexSchema.V2ExternalAgentConfigImportCompletedNotification, "fs/changed": CodexSchema.V2FsChangedNotification, @@ -728,6 +769,7 @@ export const SERVER_NOTIFICATION_PARAMS = { "model/rerouted": CodexSchema.V2ModelReroutedNotification, "model/verification": CodexSchema.V2ModelVerificationNotification, "turn/moderationMetadata": CodexSchema.V2TurnModerationMetadataNotification, + "model/safetyBuffering/updated": CodexSchema.V2ModelSafetyBufferingUpdatedNotification, warning: CodexSchema.V2WarningNotification, guardianWarning: CodexSchema.V2GuardianWarningNotification, deprecationNotice: CodexSchema.V2DeprecationNoticeNotification, diff --git a/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts b/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts index 74154a2769f..8665ad6f436 100644 --- a/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts @@ -1,5 +1,5 @@ // This file is generated by the effect-codex-app-server package. Do not edit manually. -// Upstream protocol ref: b39f943a634a6e7ba86c3d6e8cf6d5f35e612566 +// Upstream protocol ref: 678157acaa819d5510adfe359abb5d0392cfe461 import * as CodexSchema from "./schema.gen.ts"; @@ -14,8 +14,12 @@ export const v2 = { AccountUpdatedNotification: CodexSchema.V2AccountUpdatedNotification, AgentMessageDeltaNotification: CodexSchema.V2AgentMessageDeltaNotification, AppListUpdatedNotification: CodexSchema.V2AppListUpdatedNotification, + AppsInstalledParams: CodexSchema.V2AppsInstalledParams, + AppsInstalledResponse: CodexSchema.V2AppsInstalledResponse, AppsListParams: CodexSchema.V2AppsListParams, AppsListResponse: CodexSchema.V2AppsListResponse, + AppsReadParams: CodexSchema.V2AppsReadParams, + AppsReadResponse: CodexSchema.V2AppsReadResponse, CancelLoginAccountParams: CodexSchema.V2CancelLoginAccountParams, CancelLoginAccountResponse: CodexSchema.V2CancelLoginAccountResponse, CommandExecOutputDeltaNotification: CodexSchema.V2CommandExecOutputDeltaNotification, @@ -35,8 +39,12 @@ export const v2 = { ConfigValueWriteParams: CodexSchema.V2ConfigValueWriteParams, ConfigWarningNotification: CodexSchema.V2ConfigWarningNotification, ConfigWriteResponse: CodexSchema.V2ConfigWriteResponse, + ConsumeAccountRateLimitResetCreditParams: CodexSchema.V2ConsumeAccountRateLimitResetCreditParams, + ConsumeAccountRateLimitResetCreditResponse: + CodexSchema.V2ConsumeAccountRateLimitResetCreditResponse, ContextCompactedNotification: CodexSchema.V2ContextCompactedNotification, DeprecationNoticeNotification: CodexSchema.V2DeprecationNoticeNotification, + EnvironmentConnectionNotification: CodexSchema.V2EnvironmentConnectionNotification, ErrorNotification: CodexSchema.V2ErrorNotification, ExperimentalFeatureEnablementSetParams: CodexSchema.V2ExperimentalFeatureEnablementSetParams, ExperimentalFeatureEnablementSetResponse: CodexSchema.V2ExperimentalFeatureEnablementSetResponse, @@ -46,7 +54,11 @@ export const v2 = { ExternalAgentConfigDetectResponse: CodexSchema.V2ExternalAgentConfigDetectResponse, ExternalAgentConfigImportCompletedNotification: CodexSchema.V2ExternalAgentConfigImportCompletedNotification, + ExternalAgentConfigImportHistoriesReadResponse: + CodexSchema.V2ExternalAgentConfigImportHistoriesReadResponse, ExternalAgentConfigImportParams: CodexSchema.V2ExternalAgentConfigImportParams, + ExternalAgentConfigImportProgressNotification: + CodexSchema.V2ExternalAgentConfigImportProgressNotification, ExternalAgentConfigImportResponse: CodexSchema.V2ExternalAgentConfigImportResponse, FeedbackUploadParams: CodexSchema.V2FeedbackUploadParams, FeedbackUploadResponse: CodexSchema.V2FeedbackUploadResponse, @@ -75,6 +87,7 @@ export const v2 = { GetAccountRateLimitsResponse: CodexSchema.V2GetAccountRateLimitsResponse, GetAccountResponse: CodexSchema.V2GetAccountResponse, GetAccountTokenUsageResponse: CodexSchema.V2GetAccountTokenUsageResponse, + GetWorkspaceMessagesResponse: CodexSchema.V2GetWorkspaceMessagesResponse, GuardianWarningNotification: CodexSchema.V2GuardianWarningNotification, HookCompletedNotification: CodexSchema.V2HookCompletedNotification, HooksListParams: CodexSchema.V2HooksListParams, @@ -112,6 +125,7 @@ export const v2 = { ModelProviderCapabilitiesReadParams: CodexSchema.V2ModelProviderCapabilitiesReadParams, ModelProviderCapabilitiesReadResponse: CodexSchema.V2ModelProviderCapabilitiesReadResponse, ModelReroutedNotification: CodexSchema.V2ModelReroutedNotification, + ModelSafetyBufferingUpdatedNotification: CodexSchema.V2ModelSafetyBufferingUpdatedNotification, ModelVerificationNotification: CodexSchema.V2ModelVerificationNotification, PermissionProfileListParams: CodexSchema.V2PermissionProfileListParams, PermissionProfileListResponse: CodexSchema.V2PermissionProfileListResponse, @@ -140,6 +154,7 @@ export const v2 = { PluginUninstallResponse: CodexSchema.V2PluginUninstallResponse, ProcessExitedNotification: CodexSchema.V2ProcessExitedNotification, ProcessOutputDeltaNotification: CodexSchema.V2ProcessOutputDeltaNotification, + RawResponseCompletedNotification: CodexSchema.V2RawResponseCompletedNotification, RawResponseItemCompletedNotification: CodexSchema.V2RawResponseItemCompletedNotification, ReasoningSummaryPartAddedNotification: CodexSchema.V2ReasoningSummaryPartAddedNotification, ReasoningSummaryTextDeltaNotification: CodexSchema.V2ReasoningSummaryTextDeltaNotification, diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index fb93292384d..d826df60f19 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -1,5 +1,5 @@ // This file is generated by the effect-codex-app-server package. Do not edit manually. -// Upstream protocol ref: b39f943a634a6e7ba86c3d6e8cf6d5f35e612566 +// Upstream protocol ref: 678157acaa819d5510adfe359abb5d0392cfe461 import * as Schema from "effect/Schema"; @@ -51,12 +51,17 @@ export const ClientRequest__AddCreditsNudgeCreditType = Schema.Literals(["credit export type ClientRequest__AdditionalContextKind = "untrusted" | "application"; export const ClientRequest__AdditionalContextKind = Schema.Literals(["untrusted", "application"]); -export type ClientRequest__AgentMessageInputContent = { - readonly encrypted_content: string; - readonly type: "encrypted_content"; -}; +export type ClientRequest__AgentMessageInputContent = + | { readonly text: string; readonly type: "input_text" } + | { readonly encrypted_content: string; readonly type: "encrypted_content" }; export const ClientRequest__AgentMessageInputContent = Schema.Union( [ + Schema.Struct({ + text: Schema.String, + type: Schema.Literal("input_text").annotate({ + title: "InputTextAgentMessageInputContentType", + }), + }).annotate({ title: "InputTextAgentMessageInputContent" }), Schema.Struct({ encrypted_content: Schema.String, type: Schema.Literal("encrypted_content").annotate({ @@ -77,6 +82,27 @@ export const ClientRequest__ApprovalsReviewer = Schema.Literals([ "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", }); +export type ClientRequest__AppsInstalledParams = { + readonly forceRefresh?: boolean; + readonly threadId?: string | null; +}; +export const ClientRequest__AppsInstalledParams = Schema.Struct({ + forceRefresh: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "When true and Apps are permitted, refresh and publish the hosted connector runtime tool snapshot first.", + }), + ), + threadId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional loaded thread id used to evaluate effective app configuration.", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Read the committed installed connector runtime snapshot." }); + export type ClientRequest__AppsListParams = { readonly cursor?: string | null; readonly forceRefetch?: boolean; @@ -119,9 +145,25 @@ export const ClientRequest__AppsListParams = Schema.Struct({ ), }).annotate({ description: "EXPERIMENTAL - list available apps/connectors." }); +export type ClientRequest__AppsReadParams = { + readonly appIds: ReadonlyArray; + readonly includeTools?: boolean; +}; +export const ClientRequest__AppsReadParams = Schema.Struct({ + appIds: Schema.Array(Schema.String).annotate({ + description: + "App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while preserving their first-request order.", + }), + includeTools: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "When true, include display-only public tool summaries in the returned metadata.", + }), + ), +}).annotate({ description: "EXPERIMENTAL - read metadata for specific apps/connectors." }); + export type ClientRequest__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -135,7 +177,7 @@ export type ClientRequest__AskForApproval = }; export const ClientRequest__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -260,6 +302,53 @@ export const ClientRequest__ConfigReadParams = Schema.Struct({ includeLayers: Schema.optionalKey(Schema.Boolean), }); +export type ClientRequest__ConsumeAccountRateLimitResetCreditParams = { + readonly creditId?: string | null; + readonly idempotencyKey: string; +}; +export const ClientRequest__ConsumeAccountRateLimitResetCreditParams = Schema.Struct({ + creditId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.", + }), + Schema.Null, + ]), + ), + idempotencyKey: Schema.String.annotate({ + description: + "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.", + }), +}); + +export type ClientRequest__ConversationTextRole = "user" | "developer" | "assistant"; +export const ClientRequest__ConversationTextRole = Schema.Literals([ + "user", + "developer", + "assistant", +]); + +export type ClientRequest__DynamicToolNamespaceTool = { + readonly deferLoading?: boolean; + readonly description: string; + readonly inputSchema: unknown; + readonly name: string; + readonly type: "function"; +}; +export const ClientRequest__DynamicToolNamespaceTool = Schema.Union( + [ + Schema.Struct({ + deferLoading: Schema.optionalKey(Schema.Boolean), + description: Schema.String, + inputSchema: Schema.Unknown, + name: Schema.String, + type: Schema.Literal("function").annotate({ title: "FunctionDynamicToolNamespaceToolType" }), + }).annotate({ title: "FunctionDynamicToolNamespaceTool" }), + ], + { mode: "oneOf" }, +); + export type ClientRequest__ExperimentalFeatureEnablementSetParams = { readonly enablement: { readonly [x: string]: boolean }; }; @@ -309,6 +398,8 @@ export const ClientRequest__ExperimentalFeatureListParams = Schema.Struct({ export type ClientRequest__ExternalAgentConfigDetectParams = { readonly cwds?: ReadonlyArray | null; readonly includeHome?: boolean; + readonly migrationSource?: string | null; + readonly source?: string | null; }; export const ClientRequest__ExternalAgentConfigDetectParams = Schema.Struct({ cwds: Schema.optionalKey( @@ -321,9 +412,27 @@ export const ClientRequest__ExternalAgentConfigDetectParams = Schema.Struct({ ), includeHome: Schema.optionalKey( Schema.Boolean.annotate({ - description: "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + description: "If true, include detection under the user's home directory.", }), ), + migrationSource: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional migration-source selector. Missing or unrecognized values use the default source.", + }), + Schema.Null, + ]), + ), + source: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Deprecated field retained for compatibility. This field is ignored; use `migrationSource` to select the migration source.", + }), + Schema.Null, + ]), + ), }); export type ClientRequest__ExternalAgentConfigMigrationItemType = @@ -335,6 +444,7 @@ export type ClientRequest__ExternalAgentConfigMigrationItemType = | "SUBAGENTS" | "HOOKS" | "COMMANDS" + | "MEMORY" | "SESSIONS"; export const ClientRequest__ExternalAgentConfigMigrationItemType = Schema.Literals([ "AGENTS_MD", @@ -345,6 +455,7 @@ export const ClientRequest__ExternalAgentConfigMigrationItemType = Schema.Litera "SUBAGENTS", "HOOKS", "COMMANDS", + "MEMORY", "SESSIONS", ]); @@ -527,6 +638,7 @@ export const ClientRequest__ImageDetail = Schema.Literals(["auto", "low", "high" export type ClientRequest__InitializeCapabilities = { readonly experimentalApi?: boolean; + readonly mcpServerOpenaiFormElicitation?: boolean; readonly optOutNotificationMethods?: ReadonlyArray | null; readonly requestAttestation?: boolean; }; @@ -537,6 +649,11 @@ export const ClientRequest__InitializeCapabilities = Schema.Struct({ default: false, }), ), + mcpServerOpenaiFormElicitation: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Allow downstream MCP servers to request OpenAI extended form elicitations.", + }), + ), optOutNotificationMethods: Schema.optionalKey( Schema.Union([ Schema.Array(Schema.String).annotate({ @@ -554,6 +671,19 @@ export const ClientRequest__InitializeCapabilities = Schema.Struct({ ), }).annotate({ description: "Client-declared capabilities negotiated during initialize." }); +export type ClientRequest__InternalChatMessageMetadataPassthrough = { + readonly turn_id?: string | null; +}; +export const ClientRequest__InternalChatMessageMetadataPassthrough = Schema.Struct({ + turn_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}).annotate({ + description: + "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", +}); + +export type ClientRequest__LegacyAppPathString = string; +export const ClientRequest__LegacyAppPathString = Schema.String; + export type ClientRequest__LocalShellAction = { readonly command: ReadonlyArray; readonly env?: { readonly [x: string]: string } | null; @@ -592,59 +722,8 @@ export const ClientRequest__LocalShellStatus = Schema.Literals([ "incomplete", ]); -export type ClientRequest__LoginAccountParams = - | { readonly apiKey: string; readonly type: "apiKey" } - | { readonly codexStreamlinedLogin?: boolean; readonly type: "chatgpt" } - | { readonly type: "chatgptDeviceCode" } - | { - readonly accessToken: string; - readonly chatgptAccountId: string; - readonly chatgptPlanType?: string | null; - readonly type: "chatgptAuthTokens"; - }; -export const ClientRequest__LoginAccountParams = Schema.Union( - [ - Schema.Struct({ - apiKey: Schema.String, - type: Schema.Literal("apiKey").annotate({ title: "ApiKeyLoginAccountParamsType" }), - }).annotate({ title: "ApiKeyLoginAccountParams" }), - Schema.Struct({ - codexStreamlinedLogin: Schema.optionalKey(Schema.Boolean), - type: Schema.Literal("chatgpt").annotate({ title: "ChatgptLoginAccountParamsType" }), - }).annotate({ title: "ChatgptLoginAccountParams" }), - Schema.Struct({ - type: Schema.Literal("chatgptDeviceCode").annotate({ - title: "ChatgptDeviceCodeLoginAccountParamsType", - }), - }).annotate({ title: "ChatgptDeviceCodeLoginAccountParams" }), - Schema.Struct({ - accessToken: Schema.String.annotate({ - description: - "Access token (JWT) supplied by the client. This token is used for backend API requests and email extraction.", - }), - chatgptAccountId: Schema.String.annotate({ - description: "Workspace/account identifier supplied by the client.", - }), - chatgptPlanType: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: - "Optional plan type supplied by the client.\n\nWhen `null`, Codex attempts to derive the plan type from access-token claims. If unavailable, the plan defaults to `unknown`.", - }), - Schema.Null, - ]), - ), - type: Schema.Literal("chatgptAuthTokens").annotate({ - title: "ChatgptAuthTokensLoginAccountParamsType", - }), - }).annotate({ - title: "ChatgptAuthTokensLoginAccountParams", - description: - "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", - }), - ], - { mode: "oneOf" }, -); +export type ClientRequest__LoginAppBrand = "codex" | "chatgpt"; +export const ClientRequest__LoginAppBrand = Schema.Literals(["codex", "chatgpt"]); export type ClientRequest__MarketplaceAddParams = { readonly refName?: string | null; @@ -684,11 +763,13 @@ export const ClientRequest__McpServerMigration = Schema.Struct({ name: Schema.St export type ClientRequest__McpServerOauthLoginParams = { readonly name: string; readonly scopes?: ReadonlyArray | null; + readonly threadId?: string | null; readonly timeoutSecs?: number | null; }; export const ClientRequest__McpServerOauthLoginParams = Schema.Struct({ name: Schema.String, scopes: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])), + threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), timeoutSecs: Schema.optionalKey( Schema.Union([Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), Schema.Null]), ), @@ -808,12 +889,14 @@ export type ClientRequest__PluginListMarketplaceKind = | "local" | "vertical" | "workspace-directory" - | "shared-with-me"; + | "shared-with-me" + | "created-by-me-remote"; export const ClientRequest__PluginListMarketplaceKind = Schema.Literals([ "local", "vertical", "workspace-directory", "shared-with-me", + "created-by-me-remote", ]); export type ClientRequest__PluginShareCheckoutParams = { readonly remotePluginId: string }; @@ -846,10 +929,11 @@ export const ClientRequest__PluginSharePrincipalType = Schema.Literals([ export type ClientRequest__PluginShareTargetRole = "reader" | "editor"; export const ClientRequest__PluginShareTargetRole = Schema.Literals(["reader", "editor"]); -export type ClientRequest__PluginShareUpdateDiscoverability = "UNLISTED" | "PRIVATE"; +export type ClientRequest__PluginShareUpdateDiscoverability = "UNLISTED" | "PRIVATE" | "LISTED"; export const ClientRequest__PluginShareUpdateDiscoverability = Schema.Literals([ "UNLISTED", "PRIVATE", + "LISTED", ]); export type ClientRequest__PluginSkillReadParams = { @@ -1042,6 +1126,9 @@ export const ClientRequest__SessionMigration = Schema.Struct({ title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); +export type ClientRequest__SkillMigration = { readonly name: string }; +export const ClientRequest__SkillMigration = Schema.Struct({ name: Schema.String }); + export type ClientRequest__SkillsListParams = { readonly cwds?: ReadonlyArray; readonly forceReload?: boolean; @@ -1236,7 +1323,7 @@ export const ClientRequest__ThreadRollbackParams = Schema.Struct({ .check(Schema.isInt()) .check(Schema.isGreaterThanOrEqualTo(0)), threadId: Schema.String, -}); +}).annotate({ description: "DEPRECATED: `thread/rollback` will be removed soon." }); export type ClientRequest__ThreadSetNameParams = { readonly name: string; @@ -1259,8 +1346,12 @@ export const ClientRequest__ThreadShellCommandParams = Schema.Struct({ threadId: Schema.String, }); -export type ClientRequest__ThreadSortKey = "created_at" | "updated_at"; -export const ClientRequest__ThreadSortKey = Schema.Literals(["created_at", "updated_at"]); +export type ClientRequest__ThreadSortKey = "created_at" | "updated_at" | "recency_at"; +export const ClientRequest__ThreadSortKey = Schema.Literals([ + "created_at", + "updated_at", + "recency_at", +]); export type ClientRequest__ThreadSource = string; export const ClientRequest__ThreadSource = Schema.String; @@ -1333,39 +1424,8 @@ export const CommandExecutionRequestApprovalParams__FileSystemAccessMode = Schem "deny", ]); -export type CommandExecutionRequestApprovalParams__FileSystemSpecialPath = - | { readonly kind: "root" } - | { readonly kind: "minimal" } - | { readonly kind: "project_roots"; readonly subpath?: string | null } - | { readonly kind: "tmpdir" } - | { readonly kind: "slash_tmp" } - | { readonly kind: "unknown"; readonly path: string; readonly subpath?: string | null }; -export const CommandExecutionRequestApprovalParams__FileSystemSpecialPath = Schema.Union( - [ - Schema.Struct({ kind: Schema.Literal("root") }).annotate({ - title: "RootFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ - title: "MinimalFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("project_roots"), - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }).annotate({ title: "KindFileSystemSpecialPath" }), - Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ - title: "TmpdirFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ - title: "SlashTmpFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("unknown"), - path: Schema.String, - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }), - ], - { mode: "oneOf" }, -); +export type CommandExecutionRequestApprovalParams__LegacyAppPathString = string; +export const CommandExecutionRequestApprovalParams__LegacyAppPathString = Schema.String; export type CommandExecutionRequestApprovalParams__NetworkApprovalProtocol = | "http" @@ -1393,7 +1453,8 @@ export const CommandExecutionRequestApprovalResponse__NetworkPolicyRuleAction = export type DynamicToolCallResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const DynamicToolCallResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -1408,6 +1469,12 @@ export const DynamicToolCallResponse__DynamicToolCallOutputContentItem = Schema. title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -1630,45 +1697,8 @@ export const PermissionsRequestApprovalParams__FileSystemAccessMode = Schema.Lit "deny", ]); -export type PermissionsRequestApprovalParams__FileSystemSpecialPath = - | { readonly kind: "root" } - | { readonly kind: "minimal" } - | { readonly kind: "project_roots"; readonly subpath?: string | null } - | { readonly kind: "tmpdir" } - | { readonly kind: "slash_tmp" } - | { readonly kind: "unknown"; readonly path: string; readonly subpath?: string | null }; -export const PermissionsRequestApprovalParams__FileSystemSpecialPath = Schema.Union( - [ - Schema.Struct({ kind: Schema.Literal("root") }).annotate({ - title: "RootFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ - title: "MinimalFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("project_roots"), - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }).annotate({ title: "KindFileSystemSpecialPath" }), - Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ - title: "TmpdirFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ - title: "SlashTmpFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("unknown"), - path: Schema.String, - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }), - ], - { mode: "oneOf" }, -); - -export type PermissionsRequestApprovalResponse__AbsolutePathBuf = string; -export const PermissionsRequestApprovalResponse__AbsolutePathBuf = Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", -}); +export type PermissionsRequestApprovalParams__LegacyAppPathString = string; +export const PermissionsRequestApprovalParams__LegacyAppPathString = Schema.String; export type PermissionsRequestApprovalResponse__AdditionalNetworkPermissions = { readonly enabled?: boolean | null; @@ -1684,39 +1714,8 @@ export const PermissionsRequestApprovalResponse__FileSystemAccessMode = Schema.L "deny", ]); -export type PermissionsRequestApprovalResponse__FileSystemSpecialPath = - | { readonly kind: "root" } - | { readonly kind: "minimal" } - | { readonly kind: "project_roots"; readonly subpath?: string | null } - | { readonly kind: "tmpdir" } - | { readonly kind: "slash_tmp" } - | { readonly kind: "unknown"; readonly path: string; readonly subpath?: string | null }; -export const PermissionsRequestApprovalResponse__FileSystemSpecialPath = Schema.Union( - [ - Schema.Struct({ kind: Schema.Literal("root") }).annotate({ - title: "RootFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ - title: "MinimalFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("project_roots"), - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }).annotate({ title: "KindFileSystemSpecialPath" }), - Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ - title: "TmpdirFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ - title: "SlashTmpFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("unknown"), - path: Schema.String, - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }), - ], - { mode: "oneOf" }, -); +export type PermissionsRequestApprovalResponse__LegacyAppPathString = string; +export const PermissionsRequestApprovalResponse__LegacyAppPathString = Schema.String; export type ServerNotification__AbsolutePathBuf = string; export const ServerNotification__AbsolutePathBuf = Schema.String.annotate({ @@ -1821,7 +1820,6 @@ export const ServerNotification__ApprovalsReviewer = Schema.Literals([ export type ServerNotification__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -1835,7 +1833,7 @@ export type ServerNotification__AskForApproval = }; export const ServerNotification__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -1853,14 +1851,18 @@ export type ServerNotification__AuthMode = | "apikey" | "chatgpt" | "chatgptAuthTokens" + | "headers" | "agentIdentity" - | "personalAccessToken"; + | "personalAccessToken" + | "bedrockApiKey"; export const ServerNotification__AuthMode = Schema.Literals([ "apikey", "chatgpt", "chatgptAuthTokens", + "headers", "agentIdentity", "personalAccessToken", + "bedrockApiKey", ]).annotate({ description: "Authentication mode for OpenAI-backed providers." }); export type ServerNotification__AutoReviewDecisionSource = "agent"; @@ -1973,7 +1975,8 @@ export const ServerNotification__DeprecationNoticeNotification = Schema.Struct({ export type ServerNotification__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const ServerNotification__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -1988,6 +1991,12 @@ export const ServerNotification__DynamicToolCallOutputContentItem = Schema.Union title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -1999,8 +2008,38 @@ export const ServerNotification__DynamicToolCallStatus = Schema.Literals([ "failed", ]); -export type ServerNotification__ExternalAgentConfigImportCompletedNotification = {}; -export const ServerNotification__ExternalAgentConfigImportCompletedNotification = Schema.Struct({}); +export type ServerNotification__EnvironmentConnectionNotification = { + readonly environmentId: string; + readonly threadId: string; +}; +export const ServerNotification__EnvironmentConnectionNotification = Schema.Struct({ + environmentId: Schema.String, + threadId: Schema.String, +}); + +export type ServerNotification__ExternalAgentConfigMigrationItemType = + | "AGENTS_MD" + | "CONFIG" + | "SKILLS" + | "PLUGINS" + | "MCP_SERVER_CONFIG" + | "SUBAGENTS" + | "HOOKS" + | "COMMANDS" + | "MEMORY" + | "SESSIONS"; +export const ServerNotification__ExternalAgentConfigMigrationItemType = Schema.Literals([ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "MEMORY", + "SESSIONS", +]); export type ServerNotification__FileChangeOutputDeltaNotification = { readonly delta: string; @@ -2021,40 +2060,6 @@ export const ServerNotification__FileChangeOutputDeltaNotification = Schema.Stru export type ServerNotification__FileSystemAccessMode = "read" | "write" | "deny"; export const ServerNotification__FileSystemAccessMode = Schema.Literals(["read", "write", "deny"]); -export type ServerNotification__FileSystemSpecialPath = - | { readonly kind: "root" } - | { readonly kind: "minimal" } - | { readonly kind: "project_roots"; readonly subpath?: string | null } - | { readonly kind: "tmpdir" } - | { readonly kind: "slash_tmp" } - | { readonly kind: "unknown"; readonly path: string; readonly subpath?: string | null }; -export const ServerNotification__FileSystemSpecialPath = Schema.Union( - [ - Schema.Struct({ kind: Schema.Literal("root") }).annotate({ - title: "RootFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ - title: "MinimalFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("project_roots"), - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }).annotate({ title: "KindFileSystemSpecialPath" }), - Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ - title: "TmpdirFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ - title: "SlashTmpFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("unknown"), - path: Schema.String, - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }), - ], - { mode: "oneOf" }, -); - export type ServerNotification__FuzzyFileSearchMatchType = "file" | "directory"; export const ServerNotification__FuzzyFileSearchMatchType = Schema.Literals(["file", "directory"]); @@ -2127,6 +2132,7 @@ export type ServerNotification__HookEventName = | "preCompact" | "postCompact" | "sessionStart" + | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" @@ -2138,6 +2144,7 @@ export const ServerNotification__HookEventName = Schema.Literals([ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", @@ -2193,17 +2200,27 @@ export const ServerNotification__HookScope = Schema.Literals(["thread", "turn"]) export type ServerNotification__ImageDetail = "auto" | "low" | "high" | "original"; export const ServerNotification__ImageDetail = Schema.Literals(["auto", "low", "high", "original"]); +export type ServerNotification__LegacyAppPathString = string; +export const ServerNotification__LegacyAppPathString = Schema.String; + export type ServerNotification__McpServerOauthLoginCompletedNotification = { readonly error?: string | null; readonly name: string; readonly success: boolean; + readonly threadId?: string | null; }; export const ServerNotification__McpServerOauthLoginCompletedNotification = Schema.Struct({ error: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, success: Schema.Boolean, + threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); +export type ServerNotification__McpServerStartupFailureReason = "reauthenticationRequired"; +export const ServerNotification__McpServerStartupFailureReason = Schema.Literal( + "reauthenticationRequired", +); + export type ServerNotification__McpServerStartupState = | "starting" | "ready" @@ -2216,6 +2233,21 @@ export const ServerNotification__McpServerStartupState = Schema.Literals([ "cancelled", ]); +export type ServerNotification__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const ServerNotification__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type ServerNotification__McpToolCallError = { readonly message: string }; export const ServerNotification__McpToolCallError = Schema.Struct({ message: Schema.String }); @@ -2284,6 +2316,25 @@ export const ServerNotification__ModeKind = Schema.Literals(["plan", "default"]) export type ServerNotification__ModelRerouteReason = "highRiskCyberActivity"; export const ServerNotification__ModelRerouteReason = Schema.Literal("highRiskCyberActivity"); +export type ServerNotification__ModelSafetyBufferingUpdatedNotification = { + readonly fasterModel?: string | null; + readonly model: string; + readonly reasons: ReadonlyArray; + readonly showBufferingUi: boolean; + readonly threadId: string; + readonly turnId: string; + readonly useCases: ReadonlyArray; +}; +export const ServerNotification__ModelSafetyBufferingUpdatedNotification = Schema.Struct({ + fasterModel: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + model: Schema.String, + reasons: Schema.Array(Schema.String), + showBufferingUi: Schema.Boolean, + threadId: Schema.String, + turnId: Schema.String, + useCases: Schema.Array(Schema.String), +}); + export type ServerNotification__ModelVerification = "trustedAccessForCyber"; export const ServerNotification__ModelVerification = Schema.Literal("trustedAccessForCyber"); @@ -2465,8 +2516,8 @@ export const ServerNotification__RateLimitWindow = Schema.Struct({ ), }); -export type ServerNotification__RealtimeConversationVersion = "v1" | "v2"; -export const ServerNotification__RealtimeConversationVersion = Schema.Literals(["v1", "v2"]); +export type ServerNotification__RealtimeConversationVersion = "v1" | "v2" | "v3"; +export const ServerNotification__RealtimeConversationVersion = Schema.Literals(["v1", "v2", "v3"]); export type ServerNotification__ReasoningEffort = string; export const ServerNotification__ReasoningEffort = Schema.String.annotate({ @@ -2782,6 +2833,7 @@ export const ServerNotification__ThreadUnarchivedNotification = Schema.Struct({ }); export type ServerNotification__TokenUsageBreakdown = { + readonly cacheWriteInputTokens?: number; readonly cachedInputTokens: number; readonly inputTokens: number; readonly outputTokens: number; @@ -2789,6 +2841,9 @@ export type ServerNotification__TokenUsageBreakdown = { readonly totalTokens: number; }; export const ServerNotification__TokenUsageBreakdown = Schema.Struct({ + cacheWriteInputTokens: Schema.optionalKey( + Schema.Number.annotate({ default: 0, format: "int64" }).check(Schema.isInt()), + ), cachedInputTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), inputTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), outputTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), @@ -2998,39 +3053,8 @@ export const ServerRequest__FileChangeRequestApprovalParams = Schema.Struct({ export type ServerRequest__FileSystemAccessMode = "read" | "write" | "deny"; export const ServerRequest__FileSystemAccessMode = Schema.Literals(["read", "write", "deny"]); -export type ServerRequest__FileSystemSpecialPath = - | { readonly kind: "root" } - | { readonly kind: "minimal" } - | { readonly kind: "project_roots"; readonly subpath?: string | null } - | { readonly kind: "tmpdir" } - | { readonly kind: "slash_tmp" } - | { readonly kind: "unknown"; readonly path: string; readonly subpath?: string | null }; -export const ServerRequest__FileSystemSpecialPath = Schema.Union( - [ - Schema.Struct({ kind: Schema.Literal("root") }).annotate({ - title: "RootFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ - title: "MinimalFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("project_roots"), - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }).annotate({ title: "KindFileSystemSpecialPath" }), - Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ - title: "TmpdirFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ - title: "SlashTmpFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("unknown"), - path: Schema.String, - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }), - ], - { mode: "oneOf" }, -); +export type ServerRequest__LegacyAppPathString = string; +export const ServerRequest__LegacyAppPathString = Schema.String; export type ServerRequest__McpElicitationArrayType = "array"; export const ServerRequest__McpElicitationArrayType = Schema.Literal("array"); @@ -3168,6 +3192,7 @@ export const V1InitializeParams__ClientInfo = Schema.Struct({ export type V1InitializeParams__InitializeCapabilities = { readonly experimentalApi?: boolean; + readonly mcpServerOpenaiFormElicitation?: boolean; readonly optOutNotificationMethods?: ReadonlyArray | null; readonly requestAttestation?: boolean; }; @@ -3178,6 +3203,11 @@ export const V1InitializeParams__InitializeCapabilities = Schema.Struct({ default: false, }), ), + mcpServerOpenaiFormElicitation: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Allow downstream MCP servers to request OpenAI extended form elicitations.", + }), + ), optOutNotificationMethods: Schema.optionalKey( Schema.Union([ Schema.Array(Schema.String).annotate({ @@ -3280,14 +3310,18 @@ export type V2AccountUpdatedNotification__AuthMode = | "apikey" | "chatgpt" | "chatgptAuthTokens" + | "headers" | "agentIdentity" - | "personalAccessToken"; + | "personalAccessToken" + | "bedrockApiKey"; export const V2AccountUpdatedNotification__AuthMode = Schema.Literals([ "apikey", "chatgpt", "chatgptAuthTokens", + "headers", "agentIdentity", "personalAccessToken", + "bedrockApiKey", ]).annotate({ description: "Authentication mode for OpenAI-backed providers." }); export type V2AccountUpdatedNotification__PlanType = @@ -3349,6 +3383,33 @@ export const V2AppListUpdatedNotification__AppScreenshot = Schema.Struct({ userPrompt: Schema.String, }); +export type V2AppsInstalledResponse__InstalledApp = { + readonly callable: boolean; + readonly enabled: boolean; + readonly id: string; + readonly runtimeName?: string | null; +}; +export const V2AppsInstalledResponse__InstalledApp = Schema.Struct({ + callable: Schema.Boolean.annotate({ + description: + "Whether the connector is enabled and has a non-synthetic, model-visible tool allowed by effective MCP and app/tool policy in the committed runtime snapshot.", + }), + enabled: Schema.Boolean.annotate({ + description: + "Effective enabled state after applying global, workspace, local, and managed configuration at read time.", + }), + id: Schema.String, + runtimeName: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Best-effort name carried by the runtime tool catalog. Canonical app metadata remains owned by `app/read`.", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Installed connector runtime state." }); + export type V2AppsListResponse__AppBranding = { readonly category?: string | null; readonly developer?: string | null; @@ -3380,6 +3441,17 @@ export const V2AppsListResponse__AppScreenshot = Schema.Struct({ userPrompt: Schema.String, }); +export type V2AppsReadResponse__AppToolSummary = { + readonly description: string; + readonly name: string; + readonly title?: string | null; +}; +export const V2AppsReadResponse__AppToolSummary = Schema.Struct({ + description: Schema.String, + name: Schema.String, + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}).annotate({ description: "EXPERIMENTAL - metadata returned by app/read." }); + export type V2CancelLoginAccountResponse__CancelLoginAccountStatus = "canceled" | "notFound"; export const V2CancelLoginAccountResponse__CancelLoginAccountStatus = Schema.Literals([ "canceled", @@ -3429,8 +3501,13 @@ export const V2ConfigReadResponse__AnalyticsConfig = Schema.StructWithRest( [Schema.Record(Schema.String, Schema.Unknown)], ); -export type V2ConfigReadResponse__AppToolApproval = "auto" | "prompt" | "approve"; -export const V2ConfigReadResponse__AppToolApproval = Schema.Literals(["auto", "prompt", "approve"]); +export type V2ConfigReadResponse__AppToolApproval = "auto" | "prompt" | "writes" | "approve"; +export const V2ConfigReadResponse__AppToolApproval = Schema.Literals([ + "auto", + "prompt", + "writes", + "approve", +]); export type V2ConfigReadResponse__AppToolsConfig = {}; export const V2ConfigReadResponse__AppToolsConfig = Schema.Struct({}); @@ -3445,20 +3522,8 @@ export const V2ConfigReadResponse__ApprovalsReviewer = Schema.Literals([ "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", }); -export type V2ConfigReadResponse__AppsDefaultConfig = { - readonly destructive_enabled?: boolean; - readonly enabled?: boolean; - readonly open_world_enabled?: boolean; -}; -export const V2ConfigReadResponse__AppsDefaultConfig = Schema.Struct({ - destructive_enabled: Schema.optionalKey(Schema.Boolean.annotate({ default: true })), - enabled: Schema.optionalKey(Schema.Boolean.annotate({ default: true })), - open_world_enabled: Schema.optionalKey(Schema.Boolean.annotate({ default: true })), -}); - export type V2ConfigReadResponse__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -3472,7 +3537,7 @@ export type V2ConfigReadResponse__AskForApproval = }; export const V2ConfigReadResponse__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -3572,12 +3637,16 @@ export const V2ConfigReadResponse__WebSearchLocation = Schema.Struct({ timezone: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); -export type V2ConfigReadResponse__WebSearchMode = "disabled" | "cached" | "live"; -export const V2ConfigReadResponse__WebSearchMode = Schema.Literals(["disabled", "cached", "live"]); +export type V2ConfigReadResponse__WebSearchMode = "disabled" | "cached" | "indexed" | "live"; +export const V2ConfigReadResponse__WebSearchMode = Schema.Literals([ + "disabled", + "cached", + "indexed", + "live", +]); export type V2ConfigRequirementsReadResponse__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -3591,7 +3660,7 @@ export type V2ConfigRequirementsReadResponse__AskForApproval = }; export const V2ConfigRequirementsReadResponse__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -3662,6 +3731,11 @@ export const V2ConfigRequirementsReadResponse__NetworkUnixSocketPermission = Sch "deny", ]); +export type V2ConfigRequirementsReadResponse__ReasoningEffort = string; +export const V2ConfigRequirementsReadResponse__ReasoningEffort = Schema.String.annotate({ + description: "A non-empty reasoning effort value advertised by the model.", +}).check(Schema.isMinLength(1)); + export type V2ConfigRequirementsReadResponse__ResidencyRequirement = "us"; export const V2ConfigRequirementsReadResponse__ResidencyRequirement = Schema.Literal("us"); @@ -3675,10 +3749,15 @@ export const V2ConfigRequirementsReadResponse__SandboxMode = Schema.Literals([ "danger-full-access", ]); -export type V2ConfigRequirementsReadResponse__WebSearchMode = "disabled" | "cached" | "live"; +export type V2ConfigRequirementsReadResponse__WebSearchMode = + | "disabled" + | "cached" + | "indexed" + | "live"; export const V2ConfigRequirementsReadResponse__WebSearchMode = Schema.Literals([ "disabled", "cached", + "indexed", "live", ]); @@ -3716,6 +3795,11 @@ export const V2ConfigWriteResponse__AbsolutePathBuf = Schema.String.annotate({ export type V2ConfigWriteResponse__WriteStatus = "ok" | "okOverridden"; export const V2ConfigWriteResponse__WriteStatus = Schema.Literals(["ok", "okOverridden"]); +export type V2ConsumeAccountRateLimitResetCreditResponse__ConsumeAccountRateLimitResetCreditOutcome = + "reset" | "nothingToReset" | "noCredit" | "alreadyRedeemed"; +export const V2ConsumeAccountRateLimitResetCreditResponse__ConsumeAccountRateLimitResetCreditOutcome = + Schema.Literals(["reset", "nothingToReset", "noCredit", "alreadyRedeemed"]); + export type V2ErrorNotification__NonSteerableTurnKind = "review" | "compact"; export const V2ErrorNotification__NonSteerableTurnKind = Schema.Literals(["review", "compact"]); @@ -3784,6 +3868,7 @@ export type V2ExternalAgentConfigDetectResponse__ExternalAgentConfigMigrationIte | "SUBAGENTS" | "HOOKS" | "COMMANDS" + | "MEMORY" | "SESSIONS"; export const V2ExternalAgentConfigDetectResponse__ExternalAgentConfigMigrationItemType = Schema.Literals([ @@ -3795,6 +3880,7 @@ export const V2ExternalAgentConfigDetectResponse__ExternalAgentConfigMigrationIt "SUBAGENTS", "HOOKS", "COMMANDS", + "MEMORY", "SESSIONS", ]); @@ -3828,11 +3914,71 @@ export const V2ExternalAgentConfigDetectResponse__SessionMigration = Schema.Stru title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); +export type V2ExternalAgentConfigDetectResponse__SkillMigration = { readonly name: string }; +export const V2ExternalAgentConfigDetectResponse__SkillMigration = Schema.Struct({ + name: Schema.String, +}); + export type V2ExternalAgentConfigDetectResponse__SubagentMigration = { readonly name: string }; export const V2ExternalAgentConfigDetectResponse__SubagentMigration = Schema.Struct({ name: Schema.String, }); +export type V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigMigrationItemType = + | "AGENTS_MD" + | "CONFIG" + | "SKILLS" + | "PLUGINS" + | "MCP_SERVER_CONFIG" + | "SUBAGENTS" + | "HOOKS" + | "COMMANDS" + | "MEMORY" + | "SESSIONS"; +export const V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigMigrationItemType = + Schema.Literals([ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "MEMORY", + "SESSIONS", + ]); + +export type V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigMigrationItemType = + | "AGENTS_MD" + | "CONFIG" + | "SKILLS" + | "PLUGINS" + | "MCP_SERVER_CONFIG" + | "SUBAGENTS" + | "HOOKS" + | "COMMANDS" + | "MEMORY" + | "SESSIONS"; +export const V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigMigrationItemType = + Schema.Literals([ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "MEMORY", + "SESSIONS", + ]); + +export type V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentImportedConnectorSource = + "remoteMcpServersConfig"; +export const V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentImportedConnectorSource = + Schema.Literal("remoteMcpServersConfig"); + export type V2ExternalAgentConfigImportParams__CommandMigration = { readonly name: string }; export const V2ExternalAgentConfigImportParams__CommandMigration = Schema.Struct({ name: Schema.String, @@ -3847,6 +3993,7 @@ export type V2ExternalAgentConfigImportParams__ExternalAgentConfigMigrationItemT | "SUBAGENTS" | "HOOKS" | "COMMANDS" + | "MEMORY" | "SESSIONS"; export const V2ExternalAgentConfigImportParams__ExternalAgentConfigMigrationItemType = Schema.Literals([ @@ -3858,6 +4005,7 @@ export const V2ExternalAgentConfigImportParams__ExternalAgentConfigMigrationItem "SUBAGENTS", "HOOKS", "COMMANDS", + "MEMORY", "SESSIONS", ]); @@ -3891,11 +4039,41 @@ export const V2ExternalAgentConfigImportParams__SessionMigration = Schema.Struct title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); +export type V2ExternalAgentConfigImportParams__SkillMigration = { readonly name: string }; +export const V2ExternalAgentConfigImportParams__SkillMigration = Schema.Struct({ + name: Schema.String, +}); + export type V2ExternalAgentConfigImportParams__SubagentMigration = { readonly name: string }; export const V2ExternalAgentConfigImportParams__SubagentMigration = Schema.Struct({ name: Schema.String, }); +export type V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigMigrationItemType = + | "AGENTS_MD" + | "CONFIG" + | "SKILLS" + | "PLUGINS" + | "MCP_SERVER_CONFIG" + | "SUBAGENTS" + | "HOOKS" + | "COMMANDS" + | "MEMORY" + | "SESSIONS"; +export const V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigMigrationItemType = + Schema.Literals([ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "MEMORY", + "SESSIONS", + ]); + export type V2FileChangePatchUpdatedNotification__PatchChangeKind = | { readonly type: "add" } | { readonly type: "delete" } @@ -3992,6 +4170,24 @@ export const V2GetAccountRateLimitsResponse__RateLimitReachedType = Schema.Liter "workspace_member_usage_limit_reached", ]); +export type V2GetAccountRateLimitsResponse__RateLimitResetCreditStatus = + | "available" + | "redeeming" + | "redeemed" + | "unknown"; +export const V2GetAccountRateLimitsResponse__RateLimitResetCreditStatus = Schema.Literals([ + "available", + "redeeming", + "redeemed", + "unknown", +]); + +export type V2GetAccountRateLimitsResponse__RateLimitResetType = "codexRateLimits" | "unknown"; +export const V2GetAccountRateLimitsResponse__RateLimitResetType = Schema.Literals([ + "codexRateLimits", + "unknown", +]); + export type V2GetAccountRateLimitsResponse__RateLimitWindow = { readonly resetsAt?: number | null; readonly usedPercent: number; @@ -4082,6 +4278,16 @@ export const V2GetAccountTokenUsageResponse__AccountTokenUsageSummary = Schema.S ), }); +export type V2GetWorkspaceMessagesResponse__WorkspaceMessageType = + | "headline" + | "announcement" + | "unknown"; +export const V2GetWorkspaceMessagesResponse__WorkspaceMessageType = Schema.Literals([ + "headline", + "announcement", + "unknown", +]); + export type V2HookCompletedNotification__AbsolutePathBuf = string; export const V2HookCompletedNotification__AbsolutePathBuf = Schema.String.annotate({ description: @@ -4095,6 +4301,7 @@ export type V2HookCompletedNotification__HookEventName = | "preCompact" | "postCompact" | "sessionStart" + | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" @@ -4106,6 +4313,7 @@ export const V2HookCompletedNotification__HookEventName = Schema.Literals([ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", @@ -4175,6 +4383,7 @@ export type V2HooksListResponse__HookEventName = | "preCompact" | "postCompact" | "sessionStart" + | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" @@ -4186,6 +4395,7 @@ export const V2HooksListResponse__HookEventName = Schema.Literals([ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", @@ -4242,6 +4452,7 @@ export type V2HookStartedNotification__HookEventName = | "preCompact" | "postCompact" | "sessionStart" + | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" @@ -4253,6 +4464,7 @@ export const V2HookStartedNotification__HookEventName = Schema.Literals([ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", @@ -4338,7 +4550,8 @@ export const V2ItemCompletedNotification__CommandExecutionStatus = Schema.Litera export type V2ItemCompletedNotification__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ItemCompletedNotification__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -4353,6 +4566,12 @@ export const V2ItemCompletedNotification__DynamicToolCallOutputContentItem = Sch title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -4384,6 +4603,24 @@ export const V2ItemCompletedNotification__ImageDetail = Schema.Literals([ "original", ]); +export type V2ItemCompletedNotification__LegacyAppPathString = string; +export const V2ItemCompletedNotification__LegacyAppPathString = Schema.String; + +export type V2ItemCompletedNotification__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ItemCompletedNotification__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ItemCompletedNotification__McpToolCallError = { readonly message: string }; export const V2ItemCompletedNotification__McpToolCallError = Schema.Struct({ message: Schema.String, @@ -4563,41 +4800,6 @@ export type V2ItemGuardianApprovalReviewCompletedNotification__FileSystemAccessM export const V2ItemGuardianApprovalReviewCompletedNotification__FileSystemAccessMode = Schema.Literals(["read", "write", "deny"]); -export type V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSpecialPath = - | { readonly kind: "root" } - | { readonly kind: "minimal" } - | { readonly kind: "project_roots"; readonly subpath?: string | null } - | { readonly kind: "tmpdir" } - | { readonly kind: "slash_tmp" } - | { readonly kind: "unknown"; readonly path: string; readonly subpath?: string | null }; -export const V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSpecialPath = - Schema.Union( - [ - Schema.Struct({ kind: Schema.Literal("root") }).annotate({ - title: "RootFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ - title: "MinimalFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("project_roots"), - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }).annotate({ title: "KindFileSystemSpecialPath" }), - Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ - title: "TmpdirFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ - title: "SlashTmpFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("unknown"), - path: Schema.String, - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }), - ], - { mode: "oneOf" }, - ); - export type V2ItemGuardianApprovalReviewCompletedNotification__GuardianApprovalReviewStatus = | "inProgress" | "approved" @@ -4634,6 +4836,9 @@ export const V2ItemGuardianApprovalReviewCompletedNotification__GuardianUserAuth description: "[UNSTABLE] Authorization level assigned by approval auto-review.", }); +export type V2ItemGuardianApprovalReviewCompletedNotification__LegacyAppPathString = string; +export const V2ItemGuardianApprovalReviewCompletedNotification__LegacyAppPathString = Schema.String; + export type V2ItemGuardianApprovalReviewCompletedNotification__NetworkApprovalProtocol = | "http" | "https" @@ -4662,40 +4867,6 @@ export type V2ItemGuardianApprovalReviewStartedNotification__FileSystemAccessMod export const V2ItemGuardianApprovalReviewStartedNotification__FileSystemAccessMode = Schema.Literals(["read", "write", "deny"]); -export type V2ItemGuardianApprovalReviewStartedNotification__FileSystemSpecialPath = - | { readonly kind: "root" } - | { readonly kind: "minimal" } - | { readonly kind: "project_roots"; readonly subpath?: string | null } - | { readonly kind: "tmpdir" } - | { readonly kind: "slash_tmp" } - | { readonly kind: "unknown"; readonly path: string; readonly subpath?: string | null }; -export const V2ItemGuardianApprovalReviewStartedNotification__FileSystemSpecialPath = Schema.Union( - [ - Schema.Struct({ kind: Schema.Literal("root") }).annotate({ - title: "RootFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ - title: "MinimalFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("project_roots"), - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }).annotate({ title: "KindFileSystemSpecialPath" }), - Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ - title: "TmpdirFileSystemSpecialPath", - }), - Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ - title: "SlashTmpFileSystemSpecialPath", - }), - Schema.Struct({ - kind: Schema.Literal("unknown"), - path: Schema.String, - subpath: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - }), - ], - { mode: "oneOf" }, -); - export type V2ItemGuardianApprovalReviewStartedNotification__GuardianApprovalReviewStatus = | "inProgress" | "approved" @@ -4735,6 +4906,9 @@ export const V2ItemGuardianApprovalReviewStartedNotification__GuardianUserAuthor description: "[UNSTABLE] Authorization level assigned by approval auto-review.", }); +export type V2ItemGuardianApprovalReviewStartedNotification__LegacyAppPathString = string; +export const V2ItemGuardianApprovalReviewStartedNotification__LegacyAppPathString = Schema.String; + export type V2ItemGuardianApprovalReviewStartedNotification__NetworkApprovalProtocol = | "http" | "https" @@ -4781,7 +4955,8 @@ export const V2ItemStartedNotification__CommandExecutionStatus = Schema.Literals export type V2ItemStartedNotification__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ItemStartedNotification__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -4796,6 +4971,12 @@ export const V2ItemStartedNotification__DynamicToolCallOutputContentItem = Schem title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -4827,6 +5008,24 @@ export const V2ItemStartedNotification__ImageDetail = Schema.Literals([ "original", ]); +export type V2ItemStartedNotification__LegacyAppPathString = string; +export const V2ItemStartedNotification__LegacyAppPathString = Schema.String; + +export type V2ItemStartedNotification__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ItemStartedNotification__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ItemStartedNotification__McpToolCallError = { readonly message: string }; export const V2ItemStartedNotification__McpToolCallError = Schema.Struct({ message: Schema.String, @@ -5078,6 +5277,9 @@ export const V2ListMcpServerStatusResponse__Tool = Schema.Struct({ title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }).annotate({ description: "Definition for a tool the client can call." }); +export type V2LoginAccountParams__LoginAppBrand = "codex" | "chatgpt"; +export const V2LoginAccountParams__LoginAppBrand = Schema.Literals(["codex", "chatgpt"]); + export type V2MarketplaceAddResponse__AbsolutePathBuf = string; export const V2MarketplaceAddResponse__AbsolutePathBuf = Schema.String.annotate({ description: @@ -5133,6 +5335,12 @@ export const V2McpResourceReadResponse__ResourceContent = Schema.Union([ }), ]).annotate({ description: "Contents returned when reading a resource from an MCP server." }); +export type V2McpServerStatusUpdatedNotification__McpServerStartupFailureReason = + "reauthenticationRequired"; +export const V2McpServerStatusUpdatedNotification__McpServerStartupFailureReason = Schema.Literal( + "reauthenticationRequired", +); + export type V2McpServerStatusUpdatedNotification__McpServerStartupState = | "starting" | "ready" @@ -5145,10 +5353,12 @@ export const V2McpServerStatusUpdatedNotification__McpServerStartupState = Schem "cancelled", ]); -export type V2ModelListResponse__InputModality = "text" | "image"; -export const V2ModelListResponse__InputModality = Schema.Literals(["text", "image"]).annotate({ - description: "Canonical user-input modality tags advertised by a model.", -}); +export type V2ModelListResponse__InputModality = "text" | "image" | "audio"; +export const V2ModelListResponse__InputModality = Schema.Literals([ + "text", + "image", + "audio", +]).annotate({ description: "Canonical user-input modality tags advertised by a model." }); export type V2ModelListResponse__ModelAvailabilityNux = { readonly message: string }; export const V2ModelListResponse__ModelAvailabilityNux = Schema.Struct({ message: Schema.String }); @@ -5191,10 +5401,14 @@ export const V2ModelVerificationNotification__ModelVerification = Schema.Literal("trustedAccessForCyber"); export type V2PermissionProfileListResponse__PermissionProfileSummary = { + readonly allowed: boolean; readonly description?: string | null; readonly id: string; }; export const V2PermissionProfileListResponse__PermissionProfileSummary = Schema.Struct({ + allowed: Schema.Boolean.annotate({ + description: "Whether the effective requirements allow selecting this profile.", + }), description: Schema.optionalKey( Schema.Union([ Schema.String.annotate({ @@ -5241,6 +5455,14 @@ export const V2PluginInstalledResponse__PluginInstallPolicy = Schema.Literals([ "INSTALLED_BY_DEFAULT", ]); +export type V2PluginInstalledResponse__PluginInstallPolicySource = + | "WORKSPACE_SETTING" + | "IMPLICIT_CANONICAL_APP"; +export const V2PluginInstalledResponse__PluginInstallPolicySource = Schema.Literals([ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP", +]); + export type V2PluginInstalledResponse__PluginShareDiscoverability = | "LISTED" | "UNLISTED" @@ -5272,18 +5494,18 @@ export const V2PluginInstallParams__AbsolutePathBuf = Schema.String.annotate({ }); export type V2PluginInstallResponse__AppSummary = { + readonly category?: string | null; readonly description?: string | null; readonly id: string; readonly installUrl?: string | null; readonly name: string; - readonly needsAuth: boolean; }; export const V2PluginInstallResponse__AppSummary = Schema.Struct({ + category: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.String, installUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, - needsAuth: Schema.Boolean, }).annotate({ description: "EXPERIMENTAL - app metadata summary for plugin responses." }); export type V2PluginInstallResponse__PluginAuthPolicy = "ON_INSTALL" | "ON_USE"; @@ -5299,12 +5521,14 @@ export type V2PluginListParams__PluginListMarketplaceKind = | "local" | "vertical" | "workspace-directory" - | "shared-with-me"; + | "shared-with-me" + | "created-by-me-remote"; export const V2PluginListParams__PluginListMarketplaceKind = Schema.Literals([ "local", "vertical", "workspace-directory", "shared-with-me", + "created-by-me-remote", ]); export type V2PluginListResponse__AbsolutePathBuf = string; @@ -5331,6 +5555,14 @@ export const V2PluginListResponse__PluginInstallPolicy = Schema.Literals([ "INSTALLED_BY_DEFAULT", ]); +export type V2PluginListResponse__PluginInstallPolicySource = + | "WORKSPACE_SETTING" + | "IMPLICIT_CANONICAL_APP"; +export const V2PluginListResponse__PluginInstallPolicySource = Schema.Literals([ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP", +]); + export type V2PluginListResponse__PluginShareDiscoverability = "LISTED" | "UNLISTED" | "PRIVATE"; export const V2PluginListResponse__PluginShareDiscoverability = Schema.Literals([ "LISTED", @@ -5365,18 +5597,18 @@ export const V2PluginReadResponse__AbsolutePathBuf = Schema.String.annotate({ }); export type V2PluginReadResponse__AppSummary = { + readonly category?: string | null; readonly description?: string | null; readonly id: string; readonly installUrl?: string | null; readonly name: string; - readonly needsAuth: boolean; }; export const V2PluginReadResponse__AppSummary = Schema.Struct({ + category: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.String, installUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, - needsAuth: Schema.Boolean, }).annotate({ description: "EXPERIMENTAL - app metadata summary for plugin responses." }); export type V2PluginReadResponse__AppTemplateUnavailableReason = @@ -5394,6 +5626,7 @@ export type V2PluginReadResponse__HookEventName = | "preCompact" | "postCompact" | "sessionStart" + | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" @@ -5405,6 +5638,7 @@ export const V2PluginReadResponse__HookEventName = Schema.Literals([ "preCompact", "postCompact", "sessionStart", + "sessionEnd", "userPromptSubmit", "subagentStart", "subagentStop", @@ -5424,6 +5658,14 @@ export const V2PluginReadResponse__PluginInstallPolicy = Schema.Literals([ "INSTALLED_BY_DEFAULT", ]); +export type V2PluginReadResponse__PluginInstallPolicySource = + | "WORKSPACE_SETTING" + | "IMPLICIT_CANONICAL_APP"; +export const V2PluginReadResponse__PluginInstallPolicySource = Schema.Literals([ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP", +]); + export type V2PluginReadResponse__PluginShareDiscoverability = "LISTED" | "UNLISTED" | "PRIVATE"; export const V2PluginReadResponse__PluginShareDiscoverability = Schema.Literals([ "LISTED", @@ -5445,6 +5687,24 @@ export const V2PluginReadResponse__PluginSharePrincipalType = Schema.Literals([ "workspace", ]); +export type V2PluginReadResponse__ScheduledTaskWeekday = + | "MO" + | "TU" + | "WE" + | "TH" + | "FR" + | "SA" + | "SU"; +export const V2PluginReadResponse__ScheduledTaskWeekday = Schema.Literals([ + "MO", + "TU", + "WE", + "TH", + "FR", + "SA", + "SU", +]); + export type V2PluginShareCheckoutResponse__AbsolutePathBuf = string; export const V2PluginShareCheckoutResponse__AbsolutePathBuf = Schema.String.annotate({ description: @@ -5473,6 +5733,14 @@ export const V2PluginShareListResponse__PluginInstallPolicy = Schema.Literals([ "INSTALLED_BY_DEFAULT", ]); +export type V2PluginShareListResponse__PluginInstallPolicySource = + | "WORKSPACE_SETTING" + | "IMPLICIT_CANONICAL_APP"; +export const V2PluginShareListResponse__PluginInstallPolicySource = Schema.Literals([ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP", +]); + export type V2PluginShareListResponse__PluginShareDiscoverability = | "LISTED" | "UNLISTED" @@ -5538,10 +5806,12 @@ export const V2PluginShareUpdateTargetsParams__PluginShareTargetRole = Schema.Li export type V2PluginShareUpdateTargetsParams__PluginShareUpdateDiscoverability = | "UNLISTED" - | "PRIVATE"; + | "PRIVATE" + | "LISTED"; export const V2PluginShareUpdateTargetsParams__PluginShareUpdateDiscoverability = Schema.Literals([ "UNLISTED", "PRIVATE", + "LISTED", ]); export type V2PluginShareUpdateTargetsResponse__PluginShareDiscoverability = @@ -5574,12 +5844,36 @@ export const V2PluginShareUpdateTargetsResponse__PluginSharePrincipalType = Sche "workspace", ]); -export type V2RawResponseItemCompletedNotification__AgentMessageInputContent = { - readonly encrypted_content: string; - readonly type: "encrypted_content"; +export type V2RawResponseCompletedNotification__TokenUsageBreakdown = { + readonly cacheWriteInputTokens?: number; + readonly cachedInputTokens: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly reasoningOutputTokens: number; + readonly totalTokens: number; }; +export const V2RawResponseCompletedNotification__TokenUsageBreakdown = Schema.Struct({ + cacheWriteInputTokens: Schema.optionalKey( + Schema.Number.annotate({ default: 0, format: "int64" }).check(Schema.isInt()), + ), + cachedInputTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + inputTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + outputTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + reasoningOutputTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + totalTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), +}); + +export type V2RawResponseItemCompletedNotification__AgentMessageInputContent = + | { readonly text: string; readonly type: "input_text" } + | { readonly encrypted_content: string; readonly type: "encrypted_content" }; export const V2RawResponseItemCompletedNotification__AgentMessageInputContent = Schema.Union( [ + Schema.Struct({ + text: Schema.String, + type: Schema.Literal("input_text").annotate({ + title: "InputTextAgentMessageInputContentType", + }), + }).annotate({ title: "InputTextAgentMessageInputContent" }), Schema.Struct({ encrypted_content: Schema.String, type: Schema.Literal("encrypted_content").annotate({ @@ -5602,6 +5896,17 @@ export const V2RawResponseItemCompletedNotification__ImageDetail = Schema.Litera "original", ]); +export type V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough = { + readonly turn_id?: string | null; +}; +export const V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough = + Schema.Struct({ + turn_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ + description: + "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + }); + export type V2RawResponseItemCompletedNotification__LocalShellAction = { readonly command: ReadonlyArray; readonly env?: { readonly [x: string]: string } | null; @@ -5824,7 +6129,8 @@ export const V2ReviewStartResponse__CommandExecutionStatus = Schema.Literals([ export type V2ReviewStartResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ReviewStartResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -5839,6 +6145,12 @@ export const V2ReviewStartResponse__DynamicToolCallOutputContentItem = Schema.Un title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -5867,6 +6179,24 @@ export const V2ReviewStartResponse__ImageDetail = Schema.Literals([ "original", ]); +export type V2ReviewStartResponse__LegacyAppPathString = string; +export const V2ReviewStartResponse__LegacyAppPathString = Schema.String; + +export type V2ReviewStartResponse__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ReviewStartResponse__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ReviewStartResponse__McpToolCallError = { readonly message: string }; export const V2ReviewStartResponse__McpToolCallError = Schema.Struct({ message: Schema.String }); @@ -6114,7 +6444,6 @@ export const V2ThreadForkParams__ApprovalsReviewer = Schema.Literals([ export type V2ThreadForkParams__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -6128,7 +6457,7 @@ export type V2ThreadForkParams__AskForApproval = }; export const V2ThreadForkParams__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -6166,7 +6495,6 @@ export const V2ThreadForkResponse__AgentPath = Schema.String; export type V2ThreadForkResponse__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -6180,7 +6508,7 @@ export type V2ThreadForkResponse__AskForApproval = }; export const V2ThreadForkResponse__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -6226,7 +6554,8 @@ export const V2ThreadForkResponse__CommandExecutionStatus = Schema.Literals([ export type V2ThreadForkResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ThreadForkResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -6241,6 +6570,12 @@ export const V2ThreadForkResponse__DynamicToolCallOutputContentItem = Schema.Uni title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -6280,6 +6615,24 @@ export const V2ThreadForkResponse__ImageDetail = Schema.Literals([ "original", ]); +export type V2ThreadForkResponse__LegacyAppPathString = string; +export const V2ThreadForkResponse__LegacyAppPathString = Schema.String; + +export type V2ThreadForkResponse__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ThreadForkResponse__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ThreadForkResponse__McpToolCallError = { readonly message: string }; export const V2ThreadForkResponse__McpToolCallError = Schema.Struct({ message: Schema.String }); @@ -6528,8 +6881,12 @@ export const V2ThreadListParams__ThreadListCwdFilter = Schema.Union([ Schema.Array(Schema.String), ]); -export type V2ThreadListParams__ThreadSortKey = "created_at" | "updated_at"; -export const V2ThreadListParams__ThreadSortKey = Schema.Literals(["created_at", "updated_at"]); +export type V2ThreadListParams__ThreadSortKey = "created_at" | "updated_at" | "recency_at"; +export const V2ThreadListParams__ThreadSortKey = Schema.Literals([ + "created_at", + "updated_at", + "recency_at", +]); export type V2ThreadListParams__ThreadSourceKind = | "cli" @@ -6596,7 +6953,8 @@ export const V2ThreadListResponse__CommandExecutionStatus = Schema.Literals([ export type V2ThreadListResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ThreadListResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -6611,6 +6969,12 @@ export const V2ThreadListResponse__DynamicToolCallOutputContentItem = Schema.Uni title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -6650,6 +7014,24 @@ export const V2ThreadListResponse__ImageDetail = Schema.Literals([ "original", ]); +export type V2ThreadListResponse__LegacyAppPathString = string; +export const V2ThreadListResponse__LegacyAppPathString = Schema.String; + +export type V2ThreadListResponse__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ThreadListResponse__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ThreadListResponse__McpToolCallError = { readonly message: string }; export const V2ThreadListResponse__McpToolCallError = Schema.Struct({ message: Schema.String }); @@ -6901,7 +7283,8 @@ export const V2ThreadMetadataUpdateResponse__CommandExecutionStatus = Schema.Lit export type V2ThreadMetadataUpdateResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ThreadMetadataUpdateResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -6916,6 +7299,12 @@ export const V2ThreadMetadataUpdateResponse__DynamicToolCallOutputContentItem = title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -6958,6 +7347,24 @@ export const V2ThreadMetadataUpdateResponse__ImageDetail = Schema.Literals([ "original", ]); +export type V2ThreadMetadataUpdateResponse__LegacyAppPathString = string; +export const V2ThreadMetadataUpdateResponse__LegacyAppPathString = Schema.String; + +export type V2ThreadMetadataUpdateResponse__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ThreadMetadataUpdateResponse__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ThreadMetadataUpdateResponse__McpToolCallError = { readonly message: string }; export const V2ThreadMetadataUpdateResponse__McpToolCallError = Schema.Struct({ message: Schema.String, @@ -7187,7 +7594,8 @@ export const V2ThreadReadResponse__CommandExecutionStatus = Schema.Literals([ export type V2ThreadReadResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ThreadReadResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -7202,6 +7610,12 @@ export const V2ThreadReadResponse__DynamicToolCallOutputContentItem = Schema.Uni title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -7241,6 +7655,24 @@ export const V2ThreadReadResponse__ImageDetail = Schema.Literals([ "original", ]); +export type V2ThreadReadResponse__LegacyAppPathString = string; +export const V2ThreadReadResponse__LegacyAppPathString = Schema.String; + +export type V2ThreadReadResponse__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ThreadReadResponse__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ThreadReadResponse__McpToolCallError = { readonly message: string }; export const V2ThreadReadResponse__McpToolCallError = Schema.Struct({ message: Schema.String }); @@ -7444,18 +7876,24 @@ export const V2ThreadRealtimeOutputAudioDeltaNotification__ThreadRealtimeAudioCh }, ).annotate({ description: "EXPERIMENTAL - thread realtime audio chunk." }); -export type V2ThreadRealtimeStartedNotification__RealtimeConversationVersion = "v1" | "v2"; +export type V2ThreadRealtimeStartedNotification__RealtimeConversationVersion = "v1" | "v2" | "v3"; export const V2ThreadRealtimeStartedNotification__RealtimeConversationVersion = Schema.Literals([ "v1", "v2", + "v3", ]); -export type V2ThreadResumeParams__AgentMessageInputContent = { - readonly encrypted_content: string; - readonly type: "encrypted_content"; -}; +export type V2ThreadResumeParams__AgentMessageInputContent = + | { readonly text: string; readonly type: "input_text" } + | { readonly encrypted_content: string; readonly type: "encrypted_content" }; export const V2ThreadResumeParams__AgentMessageInputContent = Schema.Union( [ + Schema.Struct({ + text: Schema.String, + type: Schema.Literal("input_text").annotate({ + title: "InputTextAgentMessageInputContentType", + }), + }).annotate({ title: "InputTextAgentMessageInputContent" }), Schema.Struct({ encrypted_content: Schema.String, type: Schema.Literal("encrypted_content").annotate({ @@ -7478,7 +7916,6 @@ export const V2ThreadResumeParams__ApprovalsReviewer = Schema.Literals([ export type V2ThreadResumeParams__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -7492,7 +7929,7 @@ export type V2ThreadResumeParams__AskForApproval = }; export const V2ThreadResumeParams__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -7514,6 +7951,16 @@ export const V2ThreadResumeParams__ImageDetail = Schema.Literals([ "original", ]); +export type V2ThreadResumeParams__InternalChatMessageMetadataPassthrough = { + readonly turn_id?: string | null; +}; +export const V2ThreadResumeParams__InternalChatMessageMetadataPassthrough = Schema.Struct({ + turn_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}).annotate({ + description: + "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", +}); + export type V2ThreadResumeParams__LocalShellAction = { readonly command: ReadonlyArray; readonly env?: { readonly [x: string]: string } | null; @@ -7670,7 +8117,6 @@ export const V2ThreadResumeResponse__AgentPath = Schema.String; export type V2ThreadResumeResponse__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -7684,7 +8130,7 @@ export type V2ThreadResumeResponse__AskForApproval = }; export const V2ThreadResumeResponse__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -7730,7 +8176,8 @@ export const V2ThreadResumeResponse__CommandExecutionStatus = Schema.Literals([ export type V2ThreadResumeResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ThreadResumeResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -7745,6 +8192,12 @@ export const V2ThreadResumeResponse__DynamicToolCallOutputContentItem = Schema.U title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -7784,6 +8237,24 @@ export const V2ThreadResumeResponse__ImageDetail = Schema.Literals([ "original", ]); +export type V2ThreadResumeResponse__LegacyAppPathString = string; +export const V2ThreadResumeResponse__LegacyAppPathString = Schema.String; + +export type V2ThreadResumeResponse__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ThreadResumeResponse__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ThreadResumeResponse__McpToolCallError = { readonly message: string }; export const V2ThreadResumeResponse__McpToolCallError = Schema.Struct({ message: Schema.String }); @@ -8000,7 +8471,8 @@ export const V2ThreadRollbackResponse__CommandExecutionStatus = Schema.Literals( export type V2ThreadRollbackResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ThreadRollbackResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -8015,6 +8487,12 @@ export const V2ThreadRollbackResponse__DynamicToolCallOutputContentItem = Schema title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -8054,6 +8532,24 @@ export const V2ThreadRollbackResponse__ImageDetail = Schema.Literals([ "original", ]); +export type V2ThreadRollbackResponse__LegacyAppPathString = string; +export const V2ThreadRollbackResponse__LegacyAppPathString = Schema.String; + +export type V2ThreadRollbackResponse__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ThreadRollbackResponse__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ThreadRollbackResponse__McpToolCallError = { readonly message: string }; export const V2ThreadRollbackResponse__McpToolCallError = Schema.Struct({ message: Schema.String }); @@ -8276,7 +8772,6 @@ export const V2ThreadSettingsUpdatedNotification__ApprovalsReviewer = Schema.Lit export type V2ThreadSettingsUpdatedNotification__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -8290,7 +8785,7 @@ export type V2ThreadSettingsUpdatedNotification__AskForApproval = }; export const V2ThreadSettingsUpdatedNotification__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -8379,7 +8874,8 @@ export const V2ThreadStartedNotification__CommandExecutionStatus = Schema.Litera export type V2ThreadStartedNotification__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ThreadStartedNotification__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -8394,6 +8890,12 @@ export const V2ThreadStartedNotification__DynamicToolCallOutputContentItem = Sch title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -8436,6 +8938,24 @@ export const V2ThreadStartedNotification__ImageDetail = Schema.Literals([ "original", ]); +export type V2ThreadStartedNotification__LegacyAppPathString = string; +export const V2ThreadStartedNotification__LegacyAppPathString = Schema.String; + +export type V2ThreadStartedNotification__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ThreadStartedNotification__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ThreadStartedNotification__McpToolCallError = { readonly message: string }; export const V2ThreadStartedNotification__McpToolCallError = Schema.Struct({ message: Schema.String, @@ -8621,12 +9141,6 @@ export const V2ThreadStartedNotification__WebSearchAction = Schema.Union( { mode: "oneOf" }, ); -export type V2ThreadStartParams__AbsolutePathBuf = string; -export const V2ThreadStartParams__AbsolutePathBuf = Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", -}); - export type V2ThreadStartParams__ApprovalsReviewer = "user" | "auto_review" | "guardian_subagent"; export const V2ThreadStartParams__ApprovalsReviewer = Schema.Literals([ "user", @@ -8639,7 +9153,6 @@ export const V2ThreadStartParams__ApprovalsReviewer = Schema.Literals([ export type V2ThreadStartParams__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -8653,7 +9166,7 @@ export type V2ThreadStartParams__AskForApproval = }; export const V2ThreadStartParams__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -8667,6 +9180,29 @@ export const V2ThreadStartParams__AskForApproval = Schema.Union( { mode: "oneOf" }, ); +export type V2ThreadStartParams__DynamicToolNamespaceTool = { + readonly deferLoading?: boolean; + readonly description: string; + readonly inputSchema: unknown; + readonly name: string; + readonly type: "function"; +}; +export const V2ThreadStartParams__DynamicToolNamespaceTool = Schema.Union( + [ + Schema.Struct({ + deferLoading: Schema.optionalKey(Schema.Boolean), + description: Schema.String, + inputSchema: Schema.Unknown, + name: Schema.String, + type: Schema.Literal("function").annotate({ title: "FunctionDynamicToolNamespaceToolType" }), + }).annotate({ title: "FunctionDynamicToolNamespaceTool" }), + ], + { mode: "oneOf" }, +); + +export type V2ThreadStartParams__LegacyAppPathString = string; +export const V2ThreadStartParams__LegacyAppPathString = Schema.String; + export type V2ThreadStartParams__Personality = "none" | "friendly" | "pragmatic"; export const V2ThreadStartParams__Personality = Schema.Literals(["none", "friendly", "pragmatic"]); @@ -8697,7 +9233,6 @@ export const V2ThreadStartResponse__AgentPath = Schema.String; export type V2ThreadStartResponse__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -8711,7 +9246,7 @@ export type V2ThreadStartResponse__AskForApproval = }; export const V2ThreadStartResponse__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -8757,7 +9292,8 @@ export const V2ThreadStartResponse__CommandExecutionStatus = Schema.Literals([ export type V2ThreadStartResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ThreadStartResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -8772,6 +9308,12 @@ export const V2ThreadStartResponse__DynamicToolCallOutputContentItem = Schema.Un title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -8811,6 +9353,24 @@ export const V2ThreadStartResponse__ImageDetail = Schema.Literals([ "original", ]); +export type V2ThreadStartResponse__LegacyAppPathString = string; +export const V2ThreadStartResponse__LegacyAppPathString = Schema.String; + +export type V2ThreadStartResponse__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ThreadStartResponse__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ThreadStartResponse__McpToolCallError = { readonly message: string }; export const V2ThreadStartResponse__McpToolCallError = Schema.Struct({ message: Schema.String }); @@ -8995,6 +9555,7 @@ export const V2ThreadStatusChangedNotification__ThreadActiveFlag = Schema.Litera ]); export type V2ThreadTokenUsageUpdatedNotification__TokenUsageBreakdown = { + readonly cacheWriteInputTokens?: number; readonly cachedInputTokens: number; readonly inputTokens: number; readonly outputTokens: number; @@ -9002,6 +9563,9 @@ export type V2ThreadTokenUsageUpdatedNotification__TokenUsageBreakdown = { readonly totalTokens: number; }; export const V2ThreadTokenUsageUpdatedNotification__TokenUsageBreakdown = Schema.Struct({ + cacheWriteInputTokens: Schema.optionalKey( + Schema.Number.annotate({ default: 0, format: "int64" }).check(Schema.isInt()), + ), cachedInputTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), inputTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), outputTokens: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), @@ -9050,7 +9614,8 @@ export const V2ThreadUnarchiveResponse__CommandExecutionStatus = Schema.Literals export type V2ThreadUnarchiveResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2ThreadUnarchiveResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -9065,6 +9630,12 @@ export const V2ThreadUnarchiveResponse__DynamicToolCallOutputContentItem = Schem title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -9107,6 +9678,24 @@ export const V2ThreadUnarchiveResponse__ImageDetail = Schema.Literals([ "original", ]); +export type V2ThreadUnarchiveResponse__LegacyAppPathString = string; +export const V2ThreadUnarchiveResponse__LegacyAppPathString = Schema.String; + +export type V2ThreadUnarchiveResponse__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2ThreadUnarchiveResponse__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2ThreadUnarchiveResponse__McpToolCallError = { readonly message: string }; export const V2ThreadUnarchiveResponse__McpToolCallError = Schema.Struct({ message: Schema.String, @@ -9340,7 +9929,8 @@ export const V2TurnCompletedNotification__CommandExecutionStatus = Schema.Litera export type V2TurnCompletedNotification__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2TurnCompletedNotification__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -9355,6 +9945,12 @@ export const V2TurnCompletedNotification__DynamicToolCallOutputContentItem = Sch title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -9386,6 +9982,24 @@ export const V2TurnCompletedNotification__ImageDetail = Schema.Literals([ "original", ]); +export type V2TurnCompletedNotification__LegacyAppPathString = string; +export const V2TurnCompletedNotification__LegacyAppPathString = Schema.String; + +export type V2TurnCompletedNotification__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2TurnCompletedNotification__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2TurnCompletedNotification__McpToolCallError = { readonly message: string }; export const V2TurnCompletedNotification__McpToolCallError = Schema.Struct({ message: Schema.String, @@ -9605,7 +10219,8 @@ export const V2TurnStartedNotification__CommandExecutionStatus = Schema.Literals export type V2TurnStartedNotification__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2TurnStartedNotification__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -9620,6 +10235,12 @@ export const V2TurnStartedNotification__DynamicToolCallOutputContentItem = Schem title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -9651,6 +10272,24 @@ export const V2TurnStartedNotification__ImageDetail = Schema.Literals([ "original", ]); +export type V2TurnStartedNotification__LegacyAppPathString = string; +export const V2TurnStartedNotification__LegacyAppPathString = Schema.String; + +export type V2TurnStartedNotification__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2TurnStartedNotification__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2TurnStartedNotification__McpToolCallError = { readonly message: string }; export const V2TurnStartedNotification__McpToolCallError = Schema.Struct({ message: Schema.String, @@ -9846,7 +10485,6 @@ export const V2TurnStartParams__ApprovalsReviewer = Schema.Literals([ export type V2TurnStartParams__AskForApproval = | "untrusted" - | "on-failure" | "on-request" | "never" | { @@ -9860,7 +10498,7 @@ export type V2TurnStartParams__AskForApproval = }; export const V2TurnStartParams__AskForApproval = Schema.Union( [ - Schema.Literals(["untrusted", "on-failure", "on-request", "never"]), + Schema.Literals(["untrusted", "on-request", "never"]), Schema.Struct({ granular: Schema.Struct({ mcp_elicitations: Schema.Boolean, @@ -9877,6 +10515,9 @@ export const V2TurnStartParams__AskForApproval = Schema.Union( export type V2TurnStartParams__ImageDetail = "auto" | "low" | "high" | "original"; export const V2TurnStartParams__ImageDetail = Schema.Literals(["auto", "low", "high", "original"]); +export type V2TurnStartParams__LegacyAppPathString = string; +export const V2TurnStartParams__LegacyAppPathString = Schema.String; + export type V2TurnStartParams__ModeKind = "plan" | "default"; export const V2TurnStartParams__ModeKind = Schema.Literals(["plan", "default"]).annotate({ description: "Initial collaboration mode to use when the TUI starts.", @@ -9965,7 +10606,8 @@ export const V2TurnStartResponse__CommandExecutionStatus = Schema.Literals([ export type V2TurnStartResponse__DynamicToolCallOutputContentItem = | { readonly text: string; readonly type: "inputText" } - | { readonly imageUrl: string; readonly type: "inputImage" }; + | { readonly imageUrl: string; readonly type: "inputImage" } + | { readonly audioUrl: string; readonly type: "inputAudio" }; export const V2TurnStartResponse__DynamicToolCallOutputContentItem = Schema.Union( [ Schema.Struct({ @@ -9980,6 +10622,12 @@ export const V2TurnStartResponse__DynamicToolCallOutputContentItem = Schema.Unio title: "InputImageDynamicToolCallOutputContentItemType", }), }).annotate({ title: "InputImageDynamicToolCallOutputContentItem" }), + Schema.Struct({ + audioUrl: Schema.String, + type: Schema.Literal("inputAudio").annotate({ + title: "InputAudioDynamicToolCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioDynamicToolCallOutputContentItem" }), ], { mode: "oneOf" }, ); @@ -10008,6 +10656,24 @@ export const V2TurnStartResponse__ImageDetail = Schema.Literals([ "original", ]); +export type V2TurnStartResponse__LegacyAppPathString = string; +export const V2TurnStartResponse__LegacyAppPathString = Schema.String; + +export type V2TurnStartResponse__McpToolCallAppContext = { + readonly actionName?: string | null; + readonly appName?: string | null; + readonly connectorId: string; + readonly linkId?: string | null; + readonly resourceUri?: string | null; +}; +export const V2TurnStartResponse__McpToolCallAppContext = Schema.Struct({ + actionName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + appName: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + connectorId: Schema.String, + linkId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + resourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + export type V2TurnStartResponse__McpToolCallError = { readonly message: string }; export const V2TurnStartResponse__McpToolCallError = Schema.Struct({ message: Schema.String }); @@ -10367,6 +11033,7 @@ export type ClientRequest__ContentItem = readonly image_url: string; readonly type: "input_image"; } + | { readonly audio_url: string; readonly type: "input_audio" } | { readonly text: string; readonly type: "output_text" }; export const ClientRequest__ContentItem = Schema.Union( [ @@ -10379,6 +11046,10 @@ export const ClientRequest__ContentItem = Schema.Union( image_url: Schema.String, type: Schema.Literal("input_image").annotate({ title: "InputImageContentItemType" }), }).annotate({ title: "InputImageContentItem" }), + Schema.Struct({ + audio_url: Schema.String, + type: Schema.Literal("input_audio").annotate({ title: "InputAudioContentItemType" }), + }).annotate({ title: "InputAudioContentItem" }), Schema.Struct({ text: Schema.String, type: Schema.Literal("output_text").annotate({ title: "OutputTextContentItemType" }), @@ -10394,6 +11065,7 @@ export type ClientRequest__FunctionCallOutputContentItem = readonly image_url: string; readonly type: "input_image"; } + | { readonly audio_url: string; readonly type: "input_audio" } | { readonly encrypted_content: string; readonly type: "encrypted_content" }; export const ClientRequest__FunctionCallOutputContentItem = Schema.Union( [ @@ -10410,6 +11082,12 @@ export const ClientRequest__FunctionCallOutputContentItem = Schema.Union( title: "InputImageFunctionCallOutputContentItemType", }), }).annotate({ title: "InputImageFunctionCallOutputContentItem" }), + Schema.Struct({ + audio_url: Schema.String, + type: Schema.Literal("input_audio").annotate({ + title: "InputAudioFunctionCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioFunctionCallOutputContentItem" }), Schema.Struct({ encrypted_content: Schema.String, type: Schema.Literal("encrypted_content").annotate({ @@ -10434,6 +11112,78 @@ export const ClientRequest__InitializeParams = Schema.Struct({ clientInfo: ClientRequest__ClientInfo, }); +export type ClientRequest__LoginAccountParams = + | { readonly apiKey: string; readonly type: "apiKey" } + | { + readonly appBrand?: ClientRequest__LoginAppBrand | null; + readonly codexStreamlinedLogin?: boolean; + readonly type: "chatgpt"; + readonly useHostedLoginSuccessPage?: boolean; + } + | { readonly type: "chatgptDeviceCode" } + | { + readonly accessToken: string; + readonly chatgptAccountId: string; + readonly chatgptPlanType?: string | null; + readonly type: "chatgptAuthTokens"; + } + | { readonly apiKey: string; readonly region: string; readonly type: "amazonBedrock" }; +export const ClientRequest__LoginAccountParams = Schema.Union( + [ + Schema.Struct({ + apiKey: Schema.String, + type: Schema.Literal("apiKey").annotate({ title: "ApiKeyLoginAccountParamsType" }), + }).annotate({ title: "ApiKeyLoginAccountParams" }), + Schema.Struct({ + appBrand: Schema.optionalKey(Schema.Union([ClientRequest__LoginAppBrand, Schema.Null])), + codexStreamlinedLogin: Schema.optionalKey(Schema.Boolean), + type: Schema.Literal("chatgpt").annotate({ title: "ChatgptLoginAccountParamsType" }), + useHostedLoginSuccessPage: Schema.optionalKey(Schema.Boolean), + }).annotate({ title: "ChatgptLoginAccountParams" }), + Schema.Struct({ + type: Schema.Literal("chatgptDeviceCode").annotate({ + title: "ChatgptDeviceCodeLoginAccountParamsType", + }), + }).annotate({ title: "ChatgptDeviceCodeLoginAccountParams" }), + Schema.Struct({ + accessToken: Schema.String.annotate({ + description: + "Access token (JWT) supplied by the client. This token is used for backend API requests and email extraction.", + }), + chatgptAccountId: Schema.String.annotate({ + description: "Workspace/account identifier supplied by the client.", + }), + chatgptPlanType: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional plan type supplied by the client.\n\nWhen `null`, Codex attempts to derive the plan type from access-token claims. If unavailable, the plan defaults to `unknown`.", + }), + Schema.Null, + ]), + ), + type: Schema.Literal("chatgptAuthTokens").annotate({ + title: "ChatgptAuthTokensLoginAccountParamsType", + }), + }).annotate({ + title: "ChatgptAuthTokensLoginAccountParams", + description: + "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", + }), + Schema.Struct({ + apiKey: Schema.String, + region: Schema.String, + type: Schema.Literal("amazonBedrock").annotate({ + title: "AmazonBedrockLoginAccountParamsType", + }), + }).annotate({ + title: "AmazonBedrockLoginAccountParams", + description: "[UNSTABLE] Managed Amazon Bedrock login is experimental.", + }), + ], + { mode: "oneOf" }, +); + export type ClientRequest__ListMcpServerStatusParams = { readonly cursor?: string | null; readonly detail?: ClientRequest__McpServerStatusDetail | null; @@ -10616,8 +11366,10 @@ export type ClientRequest__MigrationDetails = { readonly commands?: ReadonlyArray; readonly hooks?: ReadonlyArray; readonly mcpServers?: ReadonlyArray; + readonly memory?: ReadonlyArray; readonly plugins?: ReadonlyArray; readonly sessions?: ReadonlyArray; + readonly skills?: ReadonlyArray; readonly subagents?: ReadonlyArray; }; export const ClientRequest__MigrationDetails = Schema.Struct({ @@ -10628,12 +11380,14 @@ export const ClientRequest__MigrationDetails = Schema.Struct({ mcpServers: Schema.optionalKey( Schema.Array(ClientRequest__McpServerMigration).annotate({ default: [] }), ), + memory: Schema.optionalKey(Schema.Array(Schema.String)), plugins: Schema.optionalKey( Schema.Array(ClientRequest__PluginsMigration).annotate({ default: [] }), ), sessions: Schema.optionalKey( Schema.Array(ClientRequest__SessionMigration).annotate({ default: [] }), ), + skills: Schema.optionalKey(Schema.Array(ClientRequest__SkillMigration).annotate({ default: [] })), subagents: Schema.optionalKey( Schema.Array(ClientRequest__SubagentMigration).annotate({ default: [] }), ), @@ -10655,6 +11409,8 @@ export type ClientRequest__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const ClientRequest__UserInput = Schema.Union( @@ -10679,6 +11435,14 @@ export const ClientRequest__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -10730,6 +11494,7 @@ export type ClientRequest__ThreadForkParams = { readonly cwd?: string | null; readonly developerInstructions?: string | null; readonly ephemeral?: boolean; + readonly lastTurnId?: string | null; readonly model?: string | null; readonly modelProvider?: string | null; readonly sandbox?: ClientRequest__SandboxMode | null; @@ -10752,6 +11517,15 @@ export const ClientRequest__ThreadForkParams = Schema.Struct({ cwd: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), developerInstructions: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), ephemeral: Schema.optionalKey(Schema.Boolean), + lastTurnId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.", + }), + Schema.Null, + ]), + ), model: Schema.optionalKey( Schema.Union([ Schema.String.annotate({ @@ -10965,27 +11739,47 @@ export const CommandExecutionRequestApprovalParams__CommandAction = Schema.Union { mode: "oneOf" }, ); -export type CommandExecutionRequestApprovalParams__FileSystemPath = - | { readonly path: CommandExecutionRequestApprovalParams__AbsolutePathBuf; readonly type: "path" } - | { readonly pattern: string; readonly type: "glob_pattern" } +export type CommandExecutionRequestApprovalParams__FileSystemSpecialPath = + | { readonly kind: "root" } + | { readonly kind: "minimal" } | { - readonly type: "special"; - readonly value: CommandExecutionRequestApprovalParams__FileSystemSpecialPath; + readonly kind: "project_roots"; + readonly subpath?: CommandExecutionRequestApprovalParams__LegacyAppPathString | null; + } + | { readonly kind: "tmpdir" } + | { readonly kind: "slash_tmp" } + | { + readonly kind: "unknown"; + readonly path: string; + readonly subpath?: CommandExecutionRequestApprovalParams__LegacyAppPathString | null; }; -export const CommandExecutionRequestApprovalParams__FileSystemPath = Schema.Union( +export const CommandExecutionRequestApprovalParams__FileSystemSpecialPath = Schema.Union( [ + Schema.Struct({ kind: Schema.Literal("root") }).annotate({ + title: "RootFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ + title: "MinimalFileSystemSpecialPath", + }), Schema.Struct({ - path: CommandExecutionRequestApprovalParams__AbsolutePathBuf, - type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), - }).annotate({ title: "PathFileSystemPath" }), - Schema.Struct({ - pattern: Schema.String, - type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), - }).annotate({ title: "GlobPatternFileSystemPath" }), + kind: Schema.Literal("project_roots"), + subpath: Schema.optionalKey( + Schema.Union([CommandExecutionRequestApprovalParams__LegacyAppPathString, Schema.Null]), + ), + }).annotate({ title: "KindFileSystemSpecialPath" }), + Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ + title: "TmpdirFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ + title: "SlashTmpFileSystemSpecialPath", + }), Schema.Struct({ - type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), - value: CommandExecutionRequestApprovalParams__FileSystemSpecialPath, - }).annotate({ title: "SpecialFileSystemPath" }), + kind: Schema.Literal("unknown"), + path: Schema.String, + subpath: Schema.optionalKey( + Schema.Union([CommandExecutionRequestApprovalParams__LegacyAppPathString, Schema.Null]), + ), + }), ], { mode: "oneOf" }, ); @@ -11263,52 +12057,92 @@ export const McpServerElicitationRequestParams__McpElicitationUntitledSingleSele type: McpServerElicitationRequestParams__McpElicitationStringType, }); -export type PermissionsRequestApprovalParams__FileSystemPath = - | { readonly path: PermissionsRequestApprovalParams__AbsolutePathBuf; readonly type: "path" } - | { readonly pattern: string; readonly type: "glob_pattern" } +export type PermissionsRequestApprovalParams__FileSystemSpecialPath = + | { readonly kind: "root" } + | { readonly kind: "minimal" } | { - readonly type: "special"; - readonly value: PermissionsRequestApprovalParams__FileSystemSpecialPath; + readonly kind: "project_roots"; + readonly subpath?: PermissionsRequestApprovalParams__LegacyAppPathString | null; + } + | { readonly kind: "tmpdir" } + | { readonly kind: "slash_tmp" } + | { + readonly kind: "unknown"; + readonly path: string; + readonly subpath?: PermissionsRequestApprovalParams__LegacyAppPathString | null; }; -export const PermissionsRequestApprovalParams__FileSystemPath = Schema.Union( +export const PermissionsRequestApprovalParams__FileSystemSpecialPath = Schema.Union( [ + Schema.Struct({ kind: Schema.Literal("root") }).annotate({ + title: "RootFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ + title: "MinimalFileSystemSpecialPath", + }), Schema.Struct({ - path: PermissionsRequestApprovalParams__AbsolutePathBuf, - type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), - }).annotate({ title: "PathFileSystemPath" }), - Schema.Struct({ - pattern: Schema.String, - type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), - }).annotate({ title: "GlobPatternFileSystemPath" }), + kind: Schema.Literal("project_roots"), + subpath: Schema.optionalKey( + Schema.Union([PermissionsRequestApprovalParams__LegacyAppPathString, Schema.Null]), + ), + }).annotate({ title: "KindFileSystemSpecialPath" }), + Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ + title: "TmpdirFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ + title: "SlashTmpFileSystemSpecialPath", + }), Schema.Struct({ - type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), - value: PermissionsRequestApprovalParams__FileSystemSpecialPath, - }).annotate({ title: "SpecialFileSystemPath" }), + kind: Schema.Literal("unknown"), + path: Schema.String, + subpath: Schema.optionalKey( + Schema.Union([PermissionsRequestApprovalParams__LegacyAppPathString, Schema.Null]), + ), + }), ], { mode: "oneOf" }, ); -export type PermissionsRequestApprovalResponse__FileSystemPath = - | { readonly path: PermissionsRequestApprovalResponse__AbsolutePathBuf; readonly type: "path" } - | { readonly pattern: string; readonly type: "glob_pattern" } +export type PermissionsRequestApprovalResponse__FileSystemSpecialPath = + | { readonly kind: "root" } + | { readonly kind: "minimal" } | { - readonly type: "special"; - readonly value: PermissionsRequestApprovalResponse__FileSystemSpecialPath; + readonly kind: "project_roots"; + readonly subpath?: PermissionsRequestApprovalResponse__LegacyAppPathString | null; + } + | { readonly kind: "tmpdir" } + | { readonly kind: "slash_tmp" } + | { + readonly kind: "unknown"; + readonly path: string; + readonly subpath?: PermissionsRequestApprovalResponse__LegacyAppPathString | null; }; -export const PermissionsRequestApprovalResponse__FileSystemPath = Schema.Union( +export const PermissionsRequestApprovalResponse__FileSystemSpecialPath = Schema.Union( [ + Schema.Struct({ kind: Schema.Literal("root") }).annotate({ + title: "RootFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ + title: "MinimalFileSystemSpecialPath", + }), Schema.Struct({ - path: PermissionsRequestApprovalResponse__AbsolutePathBuf, - type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), - }).annotate({ title: "PathFileSystemPath" }), - Schema.Struct({ - pattern: Schema.String, - type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), - }).annotate({ title: "GlobPatternFileSystemPath" }), + kind: Schema.Literal("project_roots"), + subpath: Schema.optionalKey( + Schema.Union([PermissionsRequestApprovalResponse__LegacyAppPathString, Schema.Null]), + ), + }).annotate({ title: "KindFileSystemSpecialPath" }), + Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ + title: "TmpdirFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ + title: "SlashTmpFileSystemSpecialPath", + }), Schema.Struct({ - type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), - value: PermissionsRequestApprovalResponse__FileSystemSpecialPath, - }).annotate({ title: "SpecialFileSystemPath" }), + kind: Schema.Literal("unknown"), + path: Schema.String, + subpath: Schema.optionalKey( + Schema.Union([PermissionsRequestApprovalResponse__LegacyAppPathString, Schema.Null]), + ), + }), ], { mode: "oneOf" }, ); @@ -11451,27 +12285,37 @@ export const ServerNotification__CollabAgentState = Schema.Struct({ status: ServerNotification__CollabAgentStatus, }); -export type ServerNotification__FileSystemPath = - | { readonly path: ServerNotification__AbsolutePathBuf; readonly type: "path" } - | { readonly pattern: string; readonly type: "glob_pattern" } - | { readonly type: "special"; readonly value: ServerNotification__FileSystemSpecialPath }; -export const ServerNotification__FileSystemPath = Schema.Union( - [ - Schema.Struct({ - path: ServerNotification__AbsolutePathBuf, - type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), - }).annotate({ title: "PathFileSystemPath" }), - Schema.Struct({ - pattern: Schema.String, - type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), - }).annotate({ title: "GlobPatternFileSystemPath" }), - Schema.Struct({ - type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), - value: ServerNotification__FileSystemSpecialPath, - }).annotate({ title: "SpecialFileSystemPath" }), - ], - { mode: "oneOf" }, -); +export type ServerNotification__ExternalAgentConfigImportItemTypeFailure = { + readonly cwd?: string | null; + readonly errorType?: string | null; + readonly failureStage: string; + readonly itemType: ServerNotification__ExternalAgentConfigMigrationItemType; + readonly message: string; + readonly source?: string | null; + readonly subErrorType?: string | null; +}; +export const ServerNotification__ExternalAgentConfigImportItemTypeFailure = Schema.Struct({ + cwd: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + errorType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + failureStage: Schema.String, + itemType: ServerNotification__ExternalAgentConfigMigrationItemType, + message: Schema.String, + source: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + subErrorType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); + +export type ServerNotification__ExternalAgentConfigImportItemTypeSuccess = { + readonly cwd?: string | null; + readonly itemType: ServerNotification__ExternalAgentConfigMigrationItemType; + readonly source?: string | null; + readonly target?: string | null; +}; +export const ServerNotification__ExternalAgentConfigImportItemTypeSuccess = Schema.Struct({ + cwd: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + itemType: ServerNotification__ExternalAgentConfigMigrationItemType, + source: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + target: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}); export type ServerNotification__FuzzyFileSearchResult = { readonly file_name: string; @@ -11528,14 +12372,63 @@ export const ServerNotification__HookOutputEntry = Schema.Struct({ text: Schema.String, }); +export type ServerNotification__FileSystemSpecialPath = + | { readonly kind: "root" } + | { readonly kind: "minimal" } + | { + readonly kind: "project_roots"; + readonly subpath?: ServerNotification__LegacyAppPathString | null; + } + | { readonly kind: "tmpdir" } + | { readonly kind: "slash_tmp" } + | { + readonly kind: "unknown"; + readonly path: string; + readonly subpath?: ServerNotification__LegacyAppPathString | null; + }; +export const ServerNotification__FileSystemSpecialPath = Schema.Union( + [ + Schema.Struct({ kind: Schema.Literal("root") }).annotate({ + title: "RootFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ + title: "MinimalFileSystemSpecialPath", + }), + Schema.Struct({ + kind: Schema.Literal("project_roots"), + subpath: Schema.optionalKey( + Schema.Union([ServerNotification__LegacyAppPathString, Schema.Null]), + ), + }).annotate({ title: "KindFileSystemSpecialPath" }), + Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ + title: "TmpdirFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ + title: "SlashTmpFileSystemSpecialPath", + }), + Schema.Struct({ + kind: Schema.Literal("unknown"), + path: Schema.String, + subpath: Schema.optionalKey( + Schema.Union([ServerNotification__LegacyAppPathString, Schema.Null]), + ), + }), + ], + { mode: "oneOf" }, +); + export type ServerNotification__McpServerStatusUpdatedNotification = { readonly error?: string | null; + readonly failureReason?: ServerNotification__McpServerStartupFailureReason | null; readonly name: string; readonly status: ServerNotification__McpServerStartupState; readonly threadId?: string | null; }; export const ServerNotification__McpServerStatusUpdatedNotification = Schema.Struct({ error: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + failureReason: Schema.optionalKey( + Schema.Union([ServerNotification__McpServerStartupFailureReason, Schema.Null]), + ), name: Schema.String, status: ServerNotification__McpServerStartupState, threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), @@ -11578,6 +12471,7 @@ export const ServerNotification__ModelVerificationNotification = Schema.Struct({ export type ServerNotification__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -11600,6 +12494,7 @@ export const ServerNotification__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -11759,6 +12654,7 @@ export type ServerNotification__RateLimitSnapshot = { readonly primary?: ServerNotification__RateLimitWindow | null; readonly rateLimitReachedType?: ServerNotification__RateLimitReachedType | null; readonly secondary?: ServerNotification__RateLimitWindow | null; + readonly spendControlReached?: boolean | null; }; export const ServerNotification__RateLimitSnapshot = Schema.Struct({ credits: Schema.optionalKey(Schema.Union([ServerNotification__CreditsSnapshot, Schema.Null])), @@ -11773,6 +12669,15 @@ export const ServerNotification__RateLimitSnapshot = Schema.Struct({ Schema.Union([ServerNotification__RateLimitReachedType, Schema.Null]), ), secondary: Schema.optionalKey(Schema.Union([ServerNotification__RateLimitWindow, Schema.Null])), + spendControlReached: Schema.optionalKey( + Schema.Union([ + Schema.Boolean.annotate({ + description: + "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.", + }), + Schema.Null, + ]), + ), }); export type ServerNotification__UserInput = @@ -11791,6 +12696,8 @@ export type ServerNotification__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const ServerNotification__UserInput = Schema.Union( @@ -11815,6 +12722,14 @@ export const ServerNotification__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -12020,24 +12935,40 @@ export const ServerRequest__ChatgptAuthTokensRefreshParams = Schema.Struct({ reason: ServerRequest__ChatgptAuthTokensRefreshReason, }); -export type ServerRequest__FileSystemPath = - | { readonly path: ServerRequest__AbsolutePathBuf; readonly type: "path" } - | { readonly pattern: string; readonly type: "glob_pattern" } - | { readonly type: "special"; readonly value: ServerRequest__FileSystemSpecialPath }; -export const ServerRequest__FileSystemPath = Schema.Union( +export type ServerRequest__FileSystemSpecialPath = + | { readonly kind: "root" } + | { readonly kind: "minimal" } + | { readonly kind: "project_roots"; readonly subpath?: ServerRequest__LegacyAppPathString | null } + | { readonly kind: "tmpdir" } + | { readonly kind: "slash_tmp" } + | { + readonly kind: "unknown"; + readonly path: string; + readonly subpath?: ServerRequest__LegacyAppPathString | null; + }; +export const ServerRequest__FileSystemSpecialPath = Schema.Union( [ + Schema.Struct({ kind: Schema.Literal("root") }).annotate({ + title: "RootFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ + title: "MinimalFileSystemSpecialPath", + }), Schema.Struct({ - path: ServerRequest__AbsolutePathBuf, - type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), - }).annotate({ title: "PathFileSystemPath" }), - Schema.Struct({ - pattern: Schema.String, - type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), - }).annotate({ title: "GlobPatternFileSystemPath" }), + kind: Schema.Literal("project_roots"), + subpath: Schema.optionalKey(Schema.Union([ServerRequest__LegacyAppPathString, Schema.Null])), + }).annotate({ title: "KindFileSystemSpecialPath" }), + Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ + title: "TmpdirFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ + title: "SlashTmpFileSystemSpecialPath", + }), Schema.Struct({ - type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), - value: ServerRequest__FileSystemSpecialPath, - }).annotate({ title: "SpecialFileSystemPath" }), + kind: Schema.Literal("unknown"), + path: Schema.String, + subpath: Schema.optionalKey(Schema.Union([ServerRequest__LegacyAppPathString, Schema.Null])), + }), ], { mode: "oneOf" }, ); @@ -12317,6 +13248,7 @@ export type V2AccountRateLimitsUpdatedNotification__RateLimitSnapshot = { readonly primary?: V2AccountRateLimitsUpdatedNotification__RateLimitWindow | null; readonly rateLimitReachedType?: V2AccountRateLimitsUpdatedNotification__RateLimitReachedType | null; readonly secondary?: V2AccountRateLimitsUpdatedNotification__RateLimitWindow | null; + readonly spendControlReached?: boolean | null; }; export const V2AccountRateLimitsUpdatedNotification__RateLimitSnapshot = Schema.Struct({ credits: Schema.optionalKey( @@ -12339,6 +13271,15 @@ export const V2AccountRateLimitsUpdatedNotification__RateLimitSnapshot = Schema. secondary: Schema.optionalKey( Schema.Union([V2AccountRateLimitsUpdatedNotification__RateLimitWindow, Schema.Null]), ), + spendControlReached: Schema.optionalKey( + Schema.Union([ + Schema.Boolean.annotate({ + description: + "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.", + }), + Schema.Null, + ]), + ), }); export type V2AppListUpdatedNotification__AppMetadata = { @@ -12403,6 +13344,23 @@ export const V2AppsListResponse__AppMetadata = Schema.Struct({ versionNotes: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); +export type V2AppsReadResponse__ConnectorMetadata = { + readonly description?: string | null; + readonly iconUrl?: string | null; + readonly id: string; + readonly name: string; + readonly toolSummaries?: ReadonlyArray | null; +}; +export const V2AppsReadResponse__ConnectorMetadata = Schema.Struct({ + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + iconUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + id: Schema.String, + name: Schema.String, + toolSummaries: Schema.optionalKey( + Schema.Union([Schema.Array(V2AppsReadResponse__AppToolSummary), Schema.Null]), + ), +}).annotate({ description: "EXPERIMENTAL - metadata returned by app/read." }); + export type V2CommandExecParams__SandboxPolicy = | { readonly type: "dangerFullAccess" } | { readonly networkAccess?: boolean; readonly type: "readOnly" } @@ -12555,6 +13513,25 @@ export const V2ConfigReadResponse__ConfigLayerSource = Schema.Union( { mode: "oneOf" }, ); +export type V2ConfigReadResponse__AppsDefaultConfig = { + readonly approvals_reviewer?: V2ConfigReadResponse__ApprovalsReviewer | null; + readonly default_tools_approval_mode?: V2ConfigReadResponse__AppToolApproval | null; + readonly destructive_enabled?: boolean; + readonly enabled?: boolean; + readonly open_world_enabled?: boolean; +}; +export const V2ConfigReadResponse__AppsDefaultConfig = Schema.Struct({ + approvals_reviewer: Schema.optionalKey( + Schema.Union([V2ConfigReadResponse__ApprovalsReviewer, Schema.Null]), + ), + default_tools_approval_mode: Schema.optionalKey( + Schema.Union([V2ConfigReadResponse__AppToolApproval, Schema.Null]), + ), + destructive_enabled: Schema.optionalKey(Schema.Boolean.annotate({ default: true })), + enabled: Schema.optionalKey(Schema.Boolean.annotate({ default: true })), + open_world_enabled: Schema.optionalKey(Schema.Boolean.annotate({ default: true })), +}); + export type V2ConfigReadResponse__WebSearchToolConfig = { readonly allowed_domains?: ReadonlyArray | null; readonly context_size?: V2ConfigReadResponse__WebSearchContextSize | null; @@ -12579,50 +13556,17 @@ export const V2ConfigRequirementsReadResponse__ConfiguredHookMatcherGroup = Sche matcher: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); -export type V2ConfigRequirementsReadResponse__ConfigRequirements = { - readonly allowAppshots?: boolean | null; - readonly allowManagedHooksOnly?: boolean | null; - readonly allowedApprovalPolicies?: ReadonlyArray | null; - readonly allowedPermissionProfiles?: { readonly [x: string]: boolean } | null; - readonly allowedSandboxModes?: ReadonlyArray | null; - readonly allowedWebSearchModes?: ReadonlyArray | null; - readonly allowedWindowsSandboxImplementations?: ReadonlyArray | null; - readonly computerUse?: V2ConfigRequirementsReadResponse__ComputerUseRequirements | null; - readonly defaultPermissions?: string | null; - readonly enforceResidency?: V2ConfigRequirementsReadResponse__ResidencyRequirement | null; - readonly featureRequirements?: { readonly [x: string]: boolean } | null; +export type V2ConfigRequirementsReadResponse__NewThreadModelDefaults = { + readonly model?: string | null; + readonly modelReasoningEffort?: V2ConfigRequirementsReadResponse__ReasoningEffort | null; + readonly serviceTier?: string | null; }; -export const V2ConfigRequirementsReadResponse__ConfigRequirements = Schema.Struct({ - allowAppshots: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), - allowManagedHooksOnly: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), - allowedApprovalPolicies: Schema.optionalKey( - Schema.Union([Schema.Array(V2ConfigRequirementsReadResponse__AskForApproval), Schema.Null]), - ), - allowedPermissionProfiles: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.Boolean), Schema.Null]), - ), - allowedSandboxModes: Schema.optionalKey( - Schema.Union([Schema.Array(V2ConfigRequirementsReadResponse__SandboxMode), Schema.Null]), - ), - allowedWebSearchModes: Schema.optionalKey( - Schema.Union([Schema.Array(V2ConfigRequirementsReadResponse__WebSearchMode), Schema.Null]), - ), - allowedWindowsSandboxImplementations: Schema.optionalKey( - Schema.Union([ - Schema.Array(V2ConfigRequirementsReadResponse__WindowsSandboxSetupMode), - Schema.Null, - ]), - ), - computerUse: Schema.optionalKey( - Schema.Union([V2ConfigRequirementsReadResponse__ComputerUseRequirements, Schema.Null]), - ), - defaultPermissions: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - enforceResidency: Schema.optionalKey( - Schema.Union([V2ConfigRequirementsReadResponse__ResidencyRequirement, Schema.Null]), - ), - featureRequirements: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.Boolean), Schema.Null]), +export const V2ConfigRequirementsReadResponse__NewThreadModelDefaults = Schema.Struct({ + model: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + modelReasoningEffort: Schema.optionalKey( + Schema.Union([V2ConfigRequirementsReadResponse__ReasoningEffort, Schema.Null]), ), + serviceTier: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); export type V2ConfigWarningNotification__TextRange = { @@ -12734,6 +13678,7 @@ export const V2ConfigWriteResponse__ConfigLayerSource = Schema.Union( export type V2ErrorNotification__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -12756,6 +13701,7 @@ export const V2ErrorNotification__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -12844,8 +13790,10 @@ export type V2ExternalAgentConfigDetectResponse__MigrationDetails = { readonly commands?: ReadonlyArray; readonly hooks?: ReadonlyArray; readonly mcpServers?: ReadonlyArray; + readonly memory?: ReadonlyArray; readonly plugins?: ReadonlyArray; readonly sessions?: ReadonlyArray; + readonly skills?: ReadonlyArray; readonly subagents?: ReadonlyArray; }; export const V2ExternalAgentConfigDetectResponse__MigrationDetails = Schema.Struct({ @@ -12858,23 +13806,120 @@ export const V2ExternalAgentConfigDetectResponse__MigrationDetails = Schema.Stru mcpServers: Schema.optionalKey( Schema.Array(V2ExternalAgentConfigDetectResponse__McpServerMigration).annotate({ default: [] }), ), + memory: Schema.optionalKey(Schema.Array(Schema.String)), plugins: Schema.optionalKey( Schema.Array(V2ExternalAgentConfigDetectResponse__PluginsMigration).annotate({ default: [] }), ), sessions: Schema.optionalKey( Schema.Array(V2ExternalAgentConfigDetectResponse__SessionMigration).annotate({ default: [] }), ), + skills: Schema.optionalKey( + Schema.Array(V2ExternalAgentConfigDetectResponse__SkillMigration).annotate({ default: [] }), + ), subagents: Schema.optionalKey( Schema.Array(V2ExternalAgentConfigDetectResponse__SubagentMigration).annotate({ default: [] }), ), }); +export type V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigImportItemTypeFailure = + { + readonly cwd?: string | null; + readonly errorType?: string | null; + readonly failureStage: string; + readonly itemType: V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigMigrationItemType; + readonly message: string; + readonly source?: string | null; + readonly subErrorType?: string | null; + }; +export const V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigImportItemTypeFailure = + Schema.Struct({ + cwd: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + errorType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + failureStage: Schema.String, + itemType: + V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigMigrationItemType, + message: Schema.String, + source: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + subErrorType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }); + +export type V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigImportItemTypeSuccess = + { + readonly cwd?: string | null; + readonly itemType: V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigMigrationItemType; + readonly source?: string | null; + readonly target?: string | null; + }; +export const V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigImportItemTypeSuccess = + Schema.Struct({ + cwd: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + itemType: + V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigMigrationItemType, + source: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + target: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }); + +export type V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigImportItemTypeFailure = + { + readonly cwd?: string | null; + readonly errorType?: string | null; + readonly failureStage: string; + readonly itemType: V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigMigrationItemType; + readonly message: string; + readonly source?: string | null; + readonly subErrorType?: string | null; + }; +export const V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigImportItemTypeFailure = + Schema.Struct({ + cwd: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + errorType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + failureStage: Schema.String, + itemType: + V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigMigrationItemType, + message: Schema.String, + source: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + subErrorType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }); + +export type V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigImportItemTypeSuccess = + { + readonly cwd?: string | null; + readonly itemType: V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigMigrationItemType; + readonly source?: string | null; + readonly target?: string | null; + }; +export const V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigImportItemTypeSuccess = + Schema.Struct({ + cwd: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + itemType: + V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigMigrationItemType, + source: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + target: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }); + +export type V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentImportedConnectorCandidate = + { + readonly name: string; + readonly sessionCount: number; + readonly source: V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentImportedConnectorSource; + }; +export const V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentImportedConnectorCandidate = + Schema.Struct({ + name: Schema.String, + sessionCount: Schema.Number.annotate({ format: "uint32" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + source: V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentImportedConnectorSource, + }); + export type V2ExternalAgentConfigImportParams__MigrationDetails = { readonly commands?: ReadonlyArray; readonly hooks?: ReadonlyArray; readonly mcpServers?: ReadonlyArray; + readonly memory?: ReadonlyArray; readonly plugins?: ReadonlyArray; readonly sessions?: ReadonlyArray; + readonly skills?: ReadonlyArray; readonly subagents?: ReadonlyArray; }; export const V2ExternalAgentConfigImportParams__MigrationDetails = Schema.Struct({ @@ -12887,17 +13932,57 @@ export const V2ExternalAgentConfigImportParams__MigrationDetails = Schema.Struct mcpServers: Schema.optionalKey( Schema.Array(V2ExternalAgentConfigImportParams__McpServerMigration).annotate({ default: [] }), ), + memory: Schema.optionalKey(Schema.Array(Schema.String)), plugins: Schema.optionalKey( Schema.Array(V2ExternalAgentConfigImportParams__PluginsMigration).annotate({ default: [] }), ), sessions: Schema.optionalKey( Schema.Array(V2ExternalAgentConfigImportParams__SessionMigration).annotate({ default: [] }), ), + skills: Schema.optionalKey( + Schema.Array(V2ExternalAgentConfigImportParams__SkillMigration).annotate({ default: [] }), + ), subagents: Schema.optionalKey( Schema.Array(V2ExternalAgentConfigImportParams__SubagentMigration).annotate({ default: [] }), ), }); +export type V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigImportItemTypeFailure = + { + readonly cwd?: string | null; + readonly errorType?: string | null; + readonly failureStage: string; + readonly itemType: V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigMigrationItemType; + readonly message: string; + readonly source?: string | null; + readonly subErrorType?: string | null; + }; +export const V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigImportItemTypeFailure = + Schema.Struct({ + cwd: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + errorType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + failureStage: Schema.String, + itemType: V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigMigrationItemType, + message: Schema.String, + source: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + subErrorType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }); + +export type V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigImportItemTypeSuccess = + { + readonly cwd?: string | null; + readonly itemType: V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigMigrationItemType; + readonly source?: string | null; + readonly target?: string | null; + }; +export const V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigImportItemTypeSuccess = + Schema.Struct({ + cwd: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + itemType: V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigMigrationItemType, + source: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + target: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }); + export type V2FileChangePatchUpdatedNotification__FileUpdateChange = { readonly diff: string; readonly kind: V2FileChangePatchUpdatedNotification__PatchChangeKind; @@ -12909,6 +13994,52 @@ export const V2FileChangePatchUpdatedNotification__FileUpdateChange = Schema.Str path: Schema.String, }); +export type V2GetAccountRateLimitsResponse__RateLimitResetCredit = { + readonly description?: string | null; + readonly expiresAt?: number | null; + readonly grantedAt: number; + readonly id: string; + readonly resetType: V2GetAccountRateLimitsResponse__RateLimitResetType; + readonly status: V2GetAccountRateLimitsResponse__RateLimitResetCreditStatus; + readonly title?: string | null; +}; +export const V2GetAccountRateLimitsResponse__RateLimitResetCredit = Schema.Struct({ + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Backend-provided display description for this credit, or `null` when unavailable.", + }), + Schema.Null, + ]), + ), + expiresAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: + "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), + grantedAt: Schema.Number.annotate({ + description: "Unix timestamp in seconds when the credit was granted.", + format: "int64", + }).check(Schema.isInt()), + id: Schema.String.annotate({ description: "Opaque backend identifier for this reset credit." }), + resetType: V2GetAccountRateLimitsResponse__RateLimitResetType, + status: V2GetAccountRateLimitsResponse__RateLimitResetCreditStatus, + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Backend-provided display title for this credit, or `null` when unavailable.", + }), + Schema.Null, + ]), + ), +}); + export type V2GetAccountRateLimitsResponse__RateLimitSnapshot = { readonly credits?: V2GetAccountRateLimitsResponse__CreditsSnapshot | null; readonly individualLimit?: V2GetAccountRateLimitsResponse__SpendControlLimitSnapshot | null; @@ -12918,6 +14049,7 @@ export type V2GetAccountRateLimitsResponse__RateLimitSnapshot = { readonly primary?: V2GetAccountRateLimitsResponse__RateLimitWindow | null; readonly rateLimitReachedType?: V2GetAccountRateLimitsResponse__RateLimitReachedType | null; readonly secondary?: V2GetAccountRateLimitsResponse__RateLimitWindow | null; + readonly spendControlReached?: boolean | null; }; export const V2GetAccountRateLimitsResponse__RateLimitSnapshot = Schema.Struct({ credits: Schema.optionalKey( @@ -12940,33 +14072,74 @@ export const V2GetAccountRateLimitsResponse__RateLimitSnapshot = Schema.Struct({ secondary: Schema.optionalKey( Schema.Union([V2GetAccountRateLimitsResponse__RateLimitWindow, Schema.Null]), ), + spendControlReached: Schema.optionalKey( + Schema.Union([ + Schema.Boolean.annotate({ + description: + "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.", + }), + Schema.Null, + ]), + ), }); export type V2GetAccountResponse__Account = | { readonly type: "apiKey" } | { - readonly email: string; + readonly email: string | null; readonly planType: V2GetAccountResponse__PlanType; readonly type: "chatgpt"; } - | { readonly type: "amazonBedrock" }; + | { readonly type: "amazonBedrock"; readonly usesCodexManagedCredentials?: boolean }; export const V2GetAccountResponse__Account = Schema.Union( [ Schema.Struct({ type: Schema.Literal("apiKey").annotate({ title: "ApiKeyAccountType" }), }).annotate({ title: "ApiKeyAccount" }), Schema.Struct({ - email: Schema.String, + email: Schema.Union([Schema.String, Schema.Null]), planType: V2GetAccountResponse__PlanType, type: Schema.Literal("chatgpt").annotate({ title: "ChatgptAccountType" }), }).annotate({ title: "ChatgptAccount" }), Schema.Struct({ type: Schema.Literal("amazonBedrock").annotate({ title: "AmazonBedrockAccountType" }), + usesCodexManagedCredentials: Schema.optionalKey(Schema.Boolean.annotate({ default: false })), }).annotate({ title: "AmazonBedrockAccount" }), ], { mode: "oneOf" }, ); +export type V2GetWorkspaceMessagesResponse__WorkspaceMessage = { + readonly archivedAt?: number | null; + readonly createdAt?: number | null; + readonly messageBody: string; + readonly messageId: string; + readonly messageType: V2GetWorkspaceMessagesResponse__WorkspaceMessageType; +}; +export const V2GetWorkspaceMessagesResponse__WorkspaceMessage = Schema.Struct({ + archivedAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) when the message was archived.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), + createdAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) when the message was created.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), + messageBody: Schema.String, + messageId: Schema.String, + messageType: V2GetWorkspaceMessagesResponse__WorkspaceMessageType, +}); + export type V2HookCompletedNotification__HookOutputEntry = { readonly kind: V2HookCompletedNotification__HookOutputEntryKind; readonly text: string; @@ -13109,6 +14282,8 @@ export type V2ItemCompletedNotification__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ItemCompletedNotification__UserInput = Schema.Union( @@ -13137,6 +14312,14 @@ export const V2ItemCompletedNotification__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -13151,34 +14334,6 @@ export const V2ItemCompletedNotification__UserInput = Schema.Union( { mode: "oneOf" }, ); -export type V2ItemGuardianApprovalReviewCompletedNotification__FileSystemPath = - | { - readonly path: V2ItemGuardianApprovalReviewCompletedNotification__AbsolutePathBuf; - readonly type: "path"; - } - | { readonly pattern: string; readonly type: "glob_pattern" } - | { - readonly type: "special"; - readonly value: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSpecialPath; - }; -export const V2ItemGuardianApprovalReviewCompletedNotification__FileSystemPath = Schema.Union( - [ - Schema.Struct({ - path: V2ItemGuardianApprovalReviewCompletedNotification__AbsolutePathBuf, - type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), - }).annotate({ title: "PathFileSystemPath" }), - Schema.Struct({ - pattern: Schema.String, - type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), - }).annotate({ title: "GlobPatternFileSystemPath" }), - Schema.Struct({ - type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), - value: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSpecialPath, - }).annotate({ title: "SpecialFileSystemPath" }), - ], - { mode: "oneOf" }, -); - export type V2ItemGuardianApprovalReviewCompletedNotification__GuardianApprovalReview = { readonly rationale?: string | null; readonly riskLevel?: V2ItemGuardianApprovalReviewCompletedNotification__GuardianRiskLevel | null; @@ -13206,33 +14361,57 @@ export const V2ItemGuardianApprovalReviewCompletedNotification__GuardianApproval "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", }); -export type V2ItemGuardianApprovalReviewStartedNotification__FileSystemPath = +export type V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSpecialPath = + | { readonly kind: "root" } + | { readonly kind: "minimal" } | { - readonly path: V2ItemGuardianApprovalReviewStartedNotification__AbsolutePathBuf; - readonly type: "path"; + readonly kind: "project_roots"; + readonly subpath?: V2ItemGuardianApprovalReviewCompletedNotification__LegacyAppPathString | null; } - | { readonly pattern: string; readonly type: "glob_pattern" } + | { readonly kind: "tmpdir" } + | { readonly kind: "slash_tmp" } | { - readonly type: "special"; - readonly value: V2ItemGuardianApprovalReviewStartedNotification__FileSystemSpecialPath; + readonly kind: "unknown"; + readonly path: string; + readonly subpath?: V2ItemGuardianApprovalReviewCompletedNotification__LegacyAppPathString | null; }; -export const V2ItemGuardianApprovalReviewStartedNotification__FileSystemPath = Schema.Union( - [ - Schema.Struct({ - path: V2ItemGuardianApprovalReviewStartedNotification__AbsolutePathBuf, - type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), - }).annotate({ title: "PathFileSystemPath" }), - Schema.Struct({ - pattern: Schema.String, - type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), - }).annotate({ title: "GlobPatternFileSystemPath" }), - Schema.Struct({ - type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), - value: V2ItemGuardianApprovalReviewStartedNotification__FileSystemSpecialPath, - }).annotate({ title: "SpecialFileSystemPath" }), - ], - { mode: "oneOf" }, -); +export const V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSpecialPath = + Schema.Union( + [ + Schema.Struct({ kind: Schema.Literal("root") }).annotate({ + title: "RootFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ + title: "MinimalFileSystemSpecialPath", + }), + Schema.Struct({ + kind: Schema.Literal("project_roots"), + subpath: Schema.optionalKey( + Schema.Union([ + V2ItemGuardianApprovalReviewCompletedNotification__LegacyAppPathString, + Schema.Null, + ]), + ), + }).annotate({ title: "KindFileSystemSpecialPath" }), + Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ + title: "TmpdirFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ + title: "SlashTmpFileSystemSpecialPath", + }), + Schema.Struct({ + kind: Schema.Literal("unknown"), + path: Schema.String, + subpath: Schema.optionalKey( + Schema.Union([ + V2ItemGuardianApprovalReviewCompletedNotification__LegacyAppPathString, + Schema.Null, + ]), + ), + }), + ], + { mode: "oneOf" }, + ); export type V2ItemGuardianApprovalReviewStartedNotification__GuardianApprovalReview = { readonly rationale?: string | null; @@ -13261,6 +14440,57 @@ export const V2ItemGuardianApprovalReviewStartedNotification__GuardianApprovalRe "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", }); +export type V2ItemGuardianApprovalReviewStartedNotification__FileSystemSpecialPath = + | { readonly kind: "root" } + | { readonly kind: "minimal" } + | { + readonly kind: "project_roots"; + readonly subpath?: V2ItemGuardianApprovalReviewStartedNotification__LegacyAppPathString | null; + } + | { readonly kind: "tmpdir" } + | { readonly kind: "slash_tmp" } + | { + readonly kind: "unknown"; + readonly path: string; + readonly subpath?: V2ItemGuardianApprovalReviewStartedNotification__LegacyAppPathString | null; + }; +export const V2ItemGuardianApprovalReviewStartedNotification__FileSystemSpecialPath = Schema.Union( + [ + Schema.Struct({ kind: Schema.Literal("root") }).annotate({ + title: "RootFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("minimal") }).annotate({ + title: "MinimalFileSystemSpecialPath", + }), + Schema.Struct({ + kind: Schema.Literal("project_roots"), + subpath: Schema.optionalKey( + Schema.Union([ + V2ItemGuardianApprovalReviewStartedNotification__LegacyAppPathString, + Schema.Null, + ]), + ), + }).annotate({ title: "KindFileSystemSpecialPath" }), + Schema.Struct({ kind: Schema.Literal("tmpdir") }).annotate({ + title: "TmpdirFileSystemSpecialPath", + }), + Schema.Struct({ kind: Schema.Literal("slash_tmp") }).annotate({ + title: "SlashTmpFileSystemSpecialPath", + }), + Schema.Struct({ + kind: Schema.Literal("unknown"), + path: Schema.String, + subpath: Schema.optionalKey( + Schema.Union([ + V2ItemGuardianApprovalReviewStartedNotification__LegacyAppPathString, + Schema.Null, + ]), + ), + }), + ], + { mode: "oneOf" }, +); + export type V2ItemStartedNotification__CommandAction = | { readonly command: string; @@ -13348,6 +14578,8 @@ export type V2ItemStartedNotification__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ItemStartedNotification__UserInput = Schema.Union( @@ -13376,6 +14608,14 @@ export const V2ItemStartedNotification__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -13437,7 +14677,9 @@ export type V2PluginInstalledResponse__PluginInterface = { readonly developerName?: string | null; readonly displayName?: string | null; readonly logo?: V2PluginInstalledResponse__AbsolutePathBuf | null; + readonly logoDark?: V2PluginInstalledResponse__AbsolutePathBuf | null; readonly logoUrl?: string | null; + readonly logoUrlDark?: string | null; readonly longDescription?: string | null; readonly privacyPolicyUrl?: string | null; readonly screenshotUrls: ReadonlyArray; @@ -13477,12 +14719,23 @@ export const V2PluginInstalledResponse__PluginInterface = Schema.Struct({ description: "Local logo path, resolved from the installed plugin package.", }), ), + logoDark: Schema.optionalKey( + Schema.Union([V2PluginInstalledResponse__AbsolutePathBuf, Schema.Null]).annotate({ + description: "Local dark-mode logo path, resolved from the installed plugin package.", + }), + ), logoUrl: Schema.optionalKey( Schema.Union([ Schema.String.annotate({ description: "Remote logo URL from the plugin catalog." }), Schema.Null, ]), ), + logoUrlDark: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Remote dark-mode logo URL from the plugin catalog." }), + Schema.Null, + ]), + ), longDescription: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), privacyPolicyUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), screenshotUrls: Schema.Array(Schema.String).annotate({ @@ -13505,6 +14758,12 @@ export type V2PluginInstalledResponse__PluginSource = readonly type: "git"; readonly url: string; } + | { + readonly package: string; + readonly registry?: string | null; + readonly type: "npm"; + readonly version?: string | null; + } | { readonly type: "remote" }; export const V2PluginInstalledResponse__PluginSource = Schema.Union( [ @@ -13519,6 +14778,25 @@ export const V2PluginInstalledResponse__PluginSource = Schema.Union( type: Schema.Literal("git").annotate({ title: "GitPluginSourceType" }), url: Schema.String, }).annotate({ title: "GitPluginSource" }), + Schema.Struct({ + package: Schema.String, + registry: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional HTTPS registry URL. Authentication stays in the user's npm config.", + }), + Schema.Null, + ]), + ), + type: Schema.Literal("npm").annotate({ title: "NpmPluginSourceType" }), + version: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional npm version or version range." }), + Schema.Null, + ]), + ), + }).annotate({ title: "NpmPluginSource" }), Schema.Struct({ type: Schema.Literal("remote").annotate({ title: "RemotePluginSourceType" }), }).annotate({ @@ -13562,7 +14840,9 @@ export type V2PluginListResponse__PluginInterface = { readonly developerName?: string | null; readonly displayName?: string | null; readonly logo?: V2PluginListResponse__AbsolutePathBuf | null; + readonly logoDark?: V2PluginListResponse__AbsolutePathBuf | null; readonly logoUrl?: string | null; + readonly logoUrlDark?: string | null; readonly longDescription?: string | null; readonly privacyPolicyUrl?: string | null; readonly screenshotUrls: ReadonlyArray; @@ -13602,12 +14882,23 @@ export const V2PluginListResponse__PluginInterface = Schema.Struct({ description: "Local logo path, resolved from the installed plugin package.", }), ), + logoDark: Schema.optionalKey( + Schema.Union([V2PluginListResponse__AbsolutePathBuf, Schema.Null]).annotate({ + description: "Local dark-mode logo path, resolved from the installed plugin package.", + }), + ), logoUrl: Schema.optionalKey( Schema.Union([ Schema.String.annotate({ description: "Remote logo URL from the plugin catalog." }), Schema.Null, ]), ), + logoUrlDark: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Remote dark-mode logo URL from the plugin catalog." }), + Schema.Null, + ]), + ), longDescription: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), privacyPolicyUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), screenshotUrls: Schema.Array(Schema.String).annotate({ @@ -13630,6 +14921,12 @@ export type V2PluginListResponse__PluginSource = readonly type: "git"; readonly url: string; } + | { + readonly package: string; + readonly registry?: string | null; + readonly type: "npm"; + readonly version?: string | null; + } | { readonly type: "remote" }; export const V2PluginListResponse__PluginSource = Schema.Union( [ @@ -13644,6 +14941,25 @@ export const V2PluginListResponse__PluginSource = Schema.Union( type: Schema.Literal("git").annotate({ title: "GitPluginSourceType" }), url: Schema.String, }).annotate({ title: "GitPluginSource" }), + Schema.Struct({ + package: Schema.String, + registry: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional HTTPS registry URL. Authentication stays in the user's npm config.", + }), + Schema.Null, + ]), + ), + type: Schema.Literal("npm").annotate({ title: "NpmPluginSourceType" }), + version: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional npm version or version range." }), + Schema.Null, + ]), + ), + }).annotate({ title: "NpmPluginSource" }), Schema.Struct({ type: Schema.Literal("remote").annotate({ title: "RemotePluginSourceType" }), }).annotate({ @@ -13678,7 +14994,9 @@ export type V2PluginReadResponse__PluginInterface = { readonly developerName?: string | null; readonly displayName?: string | null; readonly logo?: V2PluginReadResponse__AbsolutePathBuf | null; + readonly logoDark?: V2PluginReadResponse__AbsolutePathBuf | null; readonly logoUrl?: string | null; + readonly logoUrlDark?: string | null; readonly longDescription?: string | null; readonly privacyPolicyUrl?: string | null; readonly screenshotUrls: ReadonlyArray; @@ -13718,12 +15036,23 @@ export const V2PluginReadResponse__PluginInterface = Schema.Struct({ description: "Local logo path, resolved from the installed plugin package.", }), ), + logoDark: Schema.optionalKey( + Schema.Union([V2PluginReadResponse__AbsolutePathBuf, Schema.Null]).annotate({ + description: "Local dark-mode logo path, resolved from the installed plugin package.", + }), + ), logoUrl: Schema.optionalKey( Schema.Union([ Schema.String.annotate({ description: "Remote logo URL from the plugin catalog." }), Schema.Null, ]), ), + logoUrlDark: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Remote dark-mode logo URL from the plugin catalog." }), + Schema.Null, + ]), + ), longDescription: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), privacyPolicyUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), screenshotUrls: Schema.Array(Schema.String).annotate({ @@ -13746,6 +15075,12 @@ export type V2PluginReadResponse__PluginSource = readonly type: "git"; readonly url: string; } + | { + readonly package: string; + readonly registry?: string | null; + readonly type: "npm"; + readonly version?: string | null; + } | { readonly type: "remote" }; export const V2PluginReadResponse__PluginSource = Schema.Union( [ @@ -13760,6 +15095,25 @@ export const V2PluginReadResponse__PluginSource = Schema.Union( type: Schema.Literal("git").annotate({ title: "GitPluginSourceType" }), url: Schema.String, }).annotate({ title: "GitPluginSource" }), + Schema.Struct({ + package: Schema.String, + registry: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional HTTPS registry URL. Authentication stays in the user's npm config.", + }), + Schema.Null, + ]), + ), + type: Schema.Literal("npm").annotate({ title: "NpmPluginSourceType" }), + version: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional npm version or version range." }), + Schema.Null, + ]), + ), + }).annotate({ title: "NpmPluginSource" }), Schema.Struct({ type: Schema.Literal("remote").annotate({ title: "RemotePluginSourceType" }), }).annotate({ @@ -13790,6 +15144,7 @@ export const V2PluginReadResponse__SkillInterface = Schema.Struct({ export type V2PluginReadResponse__AppTemplateSummary = { readonly canonicalConnectorId?: string | null; + readonly category?: string | null; readonly description?: string | null; readonly logoUrl?: string | null; readonly logoUrlDark?: string | null; @@ -13800,6 +15155,7 @@ export type V2PluginReadResponse__AppTemplateSummary = { }; export const V2PluginReadResponse__AppTemplateSummary = Schema.Struct({ canonicalConnectorId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + category: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), logoUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), logoUrlDark: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), @@ -13833,6 +15189,47 @@ export const V2PluginReadResponse__PluginSharePrincipal = Schema.Struct({ role: V2PluginReadResponse__PluginSharePrincipalRole, }); +export type V2PluginReadResponse__ScheduledTaskSchedule = + | { + readonly days?: ReadonlyArray | null; + readonly intervalHours: number; + readonly type: "hourly"; + } + | { readonly time: string; readonly type: "daily" } + | { readonly time: string; readonly type: "weekdays" } + | { + readonly days: ReadonlyArray; + readonly time: string; + readonly type: "weekly"; + }; +export const V2PluginReadResponse__ScheduledTaskSchedule = Schema.Union( + [ + Schema.Struct({ + days: Schema.optionalKey( + Schema.Union([Schema.Array(V2PluginReadResponse__ScheduledTaskWeekday), Schema.Null]), + ), + intervalHours: Schema.Number.annotate({ format: "uint32" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + type: Schema.Literal("hourly").annotate({ title: "HourlyScheduledTaskScheduleType" }), + }).annotate({ title: "HourlyScheduledTaskSchedule" }), + Schema.Struct({ + time: Schema.String, + type: Schema.Literal("daily").annotate({ title: "DailyScheduledTaskScheduleType" }), + }).annotate({ title: "DailyScheduledTaskSchedule" }), + Schema.Struct({ + time: Schema.String, + type: Schema.Literal("weekdays").annotate({ title: "WeekdaysScheduledTaskScheduleType" }), + }).annotate({ title: "WeekdaysScheduledTaskSchedule" }), + Schema.Struct({ + days: Schema.Array(V2PluginReadResponse__ScheduledTaskWeekday), + time: Schema.String, + type: Schema.Literal("weekly").annotate({ title: "WeeklyScheduledTaskScheduleType" }), + }).annotate({ title: "WeeklyScheduledTaskSchedule" }), + ], + { mode: "oneOf" }, +); + export type V2PluginShareListResponse__PluginInterface = { readonly brandColor?: string | null; readonly capabilities: ReadonlyArray; @@ -13843,7 +15240,9 @@ export type V2PluginShareListResponse__PluginInterface = { readonly developerName?: string | null; readonly displayName?: string | null; readonly logo?: V2PluginShareListResponse__AbsolutePathBuf | null; + readonly logoDark?: V2PluginShareListResponse__AbsolutePathBuf | null; readonly logoUrl?: string | null; + readonly logoUrlDark?: string | null; readonly longDescription?: string | null; readonly privacyPolicyUrl?: string | null; readonly screenshotUrls: ReadonlyArray; @@ -13883,12 +15282,23 @@ export const V2PluginShareListResponse__PluginInterface = Schema.Struct({ description: "Local logo path, resolved from the installed plugin package.", }), ), + logoDark: Schema.optionalKey( + Schema.Union([V2PluginShareListResponse__AbsolutePathBuf, Schema.Null]).annotate({ + description: "Local dark-mode logo path, resolved from the installed plugin package.", + }), + ), logoUrl: Schema.optionalKey( Schema.Union([ Schema.String.annotate({ description: "Remote logo URL from the plugin catalog." }), Schema.Null, ]), ), + logoUrlDark: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Remote dark-mode logo URL from the plugin catalog." }), + Schema.Null, + ]), + ), longDescription: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), privacyPolicyUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), screenshotUrls: Schema.Array(Schema.String).annotate({ @@ -13911,6 +15321,12 @@ export type V2PluginShareListResponse__PluginSource = readonly type: "git"; readonly url: string; } + | { + readonly package: string; + readonly registry?: string | null; + readonly type: "npm"; + readonly version?: string | null; + } | { readonly type: "remote" }; export const V2PluginShareListResponse__PluginSource = Schema.Union( [ @@ -13925,6 +15341,25 @@ export const V2PluginShareListResponse__PluginSource = Schema.Union( type: Schema.Literal("git").annotate({ title: "GitPluginSourceType" }), url: Schema.String, }).annotate({ title: "GitPluginSource" }), + Schema.Struct({ + package: Schema.String, + registry: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional HTTPS registry URL. Authentication stays in the user's npm config.", + }), + Schema.Null, + ]), + ), + type: Schema.Literal("npm").annotate({ title: "NpmPluginSourceType" }), + version: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional npm version or version range." }), + Schema.Null, + ]), + ), + }).annotate({ title: "NpmPluginSource" }), Schema.Struct({ type: Schema.Literal("remote").annotate({ title: "RemotePluginSourceType" }), }).annotate({ @@ -13991,6 +15426,7 @@ export type V2RawResponseItemCompletedNotification__ContentItem = readonly image_url: string; readonly type: "input_image"; } + | { readonly audio_url: string; readonly type: "input_audio" } | { readonly text: string; readonly type: "output_text" }; export const V2RawResponseItemCompletedNotification__ContentItem = Schema.Union( [ @@ -14005,6 +15441,10 @@ export const V2RawResponseItemCompletedNotification__ContentItem = Schema.Union( image_url: Schema.String, type: Schema.Literal("input_image").annotate({ title: "InputImageContentItemType" }), }).annotate({ title: "InputImageContentItem" }), + Schema.Struct({ + audio_url: Schema.String, + type: Schema.Literal("input_audio").annotate({ title: "InputAudioContentItemType" }), + }).annotate({ title: "InputAudioContentItem" }), Schema.Struct({ text: Schema.String, type: Schema.Literal("output_text").annotate({ title: "OutputTextContentItemType" }), @@ -14020,6 +15460,7 @@ export type V2RawResponseItemCompletedNotification__FunctionCallOutputContentIte readonly image_url: string; readonly type: "input_image"; } + | { readonly audio_url: string; readonly type: "input_audio" } | { readonly encrypted_content: string; readonly type: "encrypted_content" }; export const V2RawResponseItemCompletedNotification__FunctionCallOutputContentItem = Schema.Union( [ @@ -14038,6 +15479,12 @@ export const V2RawResponseItemCompletedNotification__FunctionCallOutputContentIt title: "InputImageFunctionCallOutputContentItemType", }), }).annotate({ title: "InputImageFunctionCallOutputContentItem" }), + Schema.Struct({ + audio_url: Schema.String, + type: Schema.Literal("input_audio").annotate({ + title: "InputAudioFunctionCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioFunctionCallOutputContentItem" }), Schema.Struct({ encrypted_content: Schema.String, type: Schema.Literal("encrypted_content").annotate({ @@ -14113,6 +15560,7 @@ export const V2ReviewStartResponse__MemoryCitation = Schema.Struct({ export type V2ReviewStartResponse__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -14135,6 +15583,7 @@ export const V2ReviewStartResponse__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -14246,6 +15695,8 @@ export type V2ReviewStartResponse__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ReviewStartResponse__UserInput = Schema.Union( @@ -14270,6 +15721,14 @@ export const V2ReviewStartResponse__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -14370,6 +15829,7 @@ export const V2ThreadForkResponse__MemoryCitation = Schema.Struct({ export type V2ThreadForkResponse__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -14392,6 +15852,7 @@ export const V2ThreadForkResponse__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -14503,6 +15964,8 @@ export type V2ThreadForkResponse__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ThreadForkResponse__UserInput = Schema.Union( @@ -14527,6 +15990,14 @@ export const V2ThreadForkResponse__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -14705,6 +16176,7 @@ export const V2ThreadListResponse__MemoryCitation = Schema.Struct({ export type V2ThreadListResponse__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -14727,6 +16199,7 @@ export const V2ThreadListResponse__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -14838,6 +16311,8 @@ export type V2ThreadListResponse__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ThreadListResponse__UserInput = Schema.Union( @@ -14862,6 +16337,14 @@ export const V2ThreadListResponse__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -14971,6 +16454,7 @@ export const V2ThreadMetadataUpdateResponse__MemoryCitation = Schema.Struct({ export type V2ThreadMetadataUpdateResponse__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -14993,6 +16477,7 @@ export const V2ThreadMetadataUpdateResponse__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -15104,6 +16589,8 @@ export type V2ThreadMetadataUpdateResponse__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ThreadMetadataUpdateResponse__UserInput = Schema.Union( @@ -15132,6 +16619,14 @@ export const V2ThreadMetadataUpdateResponse__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -15241,6 +16736,7 @@ export const V2ThreadReadResponse__MemoryCitation = Schema.Struct({ export type V2ThreadReadResponse__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -15263,6 +16759,7 @@ export const V2ThreadReadResponse__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -15374,6 +16871,8 @@ export type V2ThreadReadResponse__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ThreadReadResponse__UserInput = Schema.Union( @@ -15398,6 +16897,14 @@ export const V2ThreadReadResponse__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -15452,6 +16959,7 @@ export type V2ThreadResumeParams__ContentItem = readonly image_url: string; readonly type: "input_image"; } + | { readonly audio_url: string; readonly type: "input_audio" } | { readonly text: string; readonly type: "output_text" }; export const V2ThreadResumeParams__ContentItem = Schema.Union( [ @@ -15464,6 +16972,10 @@ export const V2ThreadResumeParams__ContentItem = Schema.Union( image_url: Schema.String, type: Schema.Literal("input_image").annotate({ title: "InputImageContentItemType" }), }).annotate({ title: "InputImageContentItem" }), + Schema.Struct({ + audio_url: Schema.String, + type: Schema.Literal("input_audio").annotate({ title: "InputAudioContentItemType" }), + }).annotate({ title: "InputAudioContentItem" }), Schema.Struct({ text: Schema.String, type: Schema.Literal("output_text").annotate({ title: "OutputTextContentItemType" }), @@ -15479,6 +16991,7 @@ export type V2ThreadResumeParams__FunctionCallOutputContentItem = readonly image_url: string; readonly type: "input_image"; } + | { readonly audio_url: string; readonly type: "input_audio" } | { readonly encrypted_content: string; readonly type: "encrypted_content" }; export const V2ThreadResumeParams__FunctionCallOutputContentItem = Schema.Union( [ @@ -15495,6 +17008,12 @@ export const V2ThreadResumeParams__FunctionCallOutputContentItem = Schema.Union( title: "InputImageFunctionCallOutputContentItemType", }), }).annotate({ title: "InputImageFunctionCallOutputContentItem" }), + Schema.Struct({ + audio_url: Schema.String, + type: Schema.Literal("input_audio").annotate({ + title: "InputAudioFunctionCallOutputContentItemType", + }), + }).annotate({ title: "InputAudioFunctionCallOutputContentItem" }), Schema.Struct({ encrypted_content: Schema.String, type: Schema.Literal("encrypted_content").annotate({ @@ -15570,6 +17089,7 @@ export const V2ThreadResumeResponse__MemoryCitation = Schema.Struct({ export type V2ThreadResumeResponse__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -15592,6 +17112,7 @@ export const V2ThreadResumeResponse__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -15703,6 +17224,8 @@ export type V2ThreadResumeResponse__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ThreadResumeResponse__UserInput = Schema.Union( @@ -15727,6 +17250,14 @@ export const V2ThreadResumeResponse__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -15836,6 +17367,7 @@ export const V2ThreadRollbackResponse__MemoryCitation = Schema.Struct({ export type V2ThreadRollbackResponse__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -15858,6 +17390,7 @@ export const V2ThreadRollbackResponse__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -15969,6 +17502,8 @@ export type V2ThreadRollbackResponse__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ThreadRollbackResponse__UserInput = Schema.Union( @@ -15997,6 +17532,14 @@ export const V2ThreadRollbackResponse__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -16164,6 +17707,7 @@ export const V2ThreadStartedNotification__MemoryCitation = Schema.Struct({ export type V2ThreadStartedNotification__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -16186,6 +17730,7 @@ export const V2ThreadStartedNotification__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -16297,6 +17842,8 @@ export type V2ThreadStartedNotification__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ThreadStartedNotification__UserInput = Schema.Union( @@ -16325,6 +17872,14 @@ export const V2ThreadStartedNotification__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -16434,6 +17989,7 @@ export const V2ThreadStartResponse__MemoryCitation = Schema.Struct({ export type V2ThreadStartResponse__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -16456,6 +18012,7 @@ export const V2ThreadStartResponse__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -16567,6 +18124,8 @@ export type V2ThreadStartResponse__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ThreadStartResponse__UserInput = Schema.Union( @@ -16591,6 +18150,14 @@ export const V2ThreadStartResponse__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -16740,6 +18307,7 @@ export const V2ThreadUnarchiveResponse__MemoryCitation = Schema.Struct({ export type V2ThreadUnarchiveResponse__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -16762,6 +18330,7 @@ export const V2ThreadUnarchiveResponse__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -16873,6 +18442,8 @@ export type V2ThreadUnarchiveResponse__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2ThreadUnarchiveResponse__UserInput = Schema.Union( @@ -16901,6 +18472,14 @@ export const V2ThreadUnarchiveResponse__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -17010,6 +18589,7 @@ export const V2TurnCompletedNotification__MemoryCitation = Schema.Struct({ export type V2TurnCompletedNotification__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -17032,6 +18612,7 @@ export const V2TurnCompletedNotification__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -17143,6 +18724,8 @@ export type V2TurnCompletedNotification__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2TurnCompletedNotification__UserInput = Schema.Union( @@ -17171,6 +18754,14 @@ export const V2TurnCompletedNotification__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -17256,6 +18847,7 @@ export const V2TurnStartedNotification__MemoryCitation = Schema.Struct({ export type V2TurnStartedNotification__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -17278,6 +18870,7 @@ export const V2TurnStartedNotification__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -17389,6 +18982,8 @@ export type V2TurnStartedNotification__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2TurnStartedNotification__UserInput = Schema.Union( @@ -17417,6 +19012,14 @@ export const V2TurnStartedNotification__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -17503,6 +19106,8 @@ export type V2TurnStartParams__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2TurnStartParams__UserInput = Schema.Union( @@ -17527,6 +19132,14 @@ export const V2TurnStartParams__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -17603,6 +19216,7 @@ export const V2TurnStartResponse__MemoryCitation = Schema.Struct({ export type V2TurnStartResponse__CodexErrorInfo = | "contextWindowExceeded" + | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" @@ -17625,6 +19239,7 @@ export const V2TurnStartResponse__CodexErrorInfo = Schema.Union( [ Schema.Literals([ "contextWindowExceeded", + "sessionBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", @@ -17736,6 +19351,8 @@ export type V2TurnStartResponse__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2TurnStartResponse__UserInput = Schema.Union( @@ -17760,6 +19377,14 @@ export const V2TurnStartResponse__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -17790,6 +19415,8 @@ export type V2TurnSteerParams__UserInput = readonly path: string; readonly type: "localImage"; } + | { readonly type: "audio"; readonly url: string } + | { readonly path: string; readonly type: "localAudio" } | { readonly name: string; readonly path: string; readonly type: "skill" } | { readonly name: string; readonly path: string; readonly type: "mention" }; export const V2TurnSteerParams__UserInput = Schema.Union( @@ -17814,6 +19441,14 @@ export const V2TurnSteerParams__UserInput = Schema.Union( path: Schema.String, type: Schema.Literal("localImage").annotate({ title: "LocalImageUserInputType" }), }).annotate({ title: "LocalImageUserInput" }), + Schema.Struct({ + type: Schema.Literal("audio").annotate({ title: "AudioUserInputType" }), + url: Schema.String, + }).annotate({ title: "AudioUserInput" }), + Schema.Struct({ + path: Schema.String, + type: Schema.Literal("localAudio").annotate({ title: "LocalAudioUserInputType" }), + }).annotate({ title: "LocalAudioUserInput" }), Schema.Struct({ name: Schema.String, path: Schema.String, @@ -18179,14 +19814,33 @@ export const ClientRequest__TurnSteerParams = Schema.Struct({ threadId: Schema.String, }); -export type CommandExecutionRequestApprovalParams__FileSystemSandboxEntry = { - readonly access: CommandExecutionRequestApprovalParams__FileSystemAccessMode; - readonly path: CommandExecutionRequestApprovalParams__FileSystemPath; -}; -export const CommandExecutionRequestApprovalParams__FileSystemSandboxEntry = Schema.Struct({ - access: CommandExecutionRequestApprovalParams__FileSystemAccessMode, - path: CommandExecutionRequestApprovalParams__FileSystemPath, -}); +export type CommandExecutionRequestApprovalParams__FileSystemPath = + | { + readonly path: CommandExecutionRequestApprovalParams__LegacyAppPathString; + readonly type: "path"; + } + | { readonly pattern: string; readonly type: "glob_pattern" } + | { + readonly type: "special"; + readonly value: CommandExecutionRequestApprovalParams__FileSystemSpecialPath; + }; +export const CommandExecutionRequestApprovalParams__FileSystemPath = Schema.Union( + [ + Schema.Struct({ + path: CommandExecutionRequestApprovalParams__LegacyAppPathString, + type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), + }).annotate({ title: "PathFileSystemPath" }), + Schema.Struct({ + pattern: Schema.String, + type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), + }).annotate({ title: "GlobPatternFileSystemPath" }), + Schema.Struct({ + type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), + value: CommandExecutionRequestApprovalParams__FileSystemSpecialPath, + }).annotate({ title: "SpecialFileSystemPath" }), + ], + { mode: "oneOf" }, +); export type CommandExecutionRequestApprovalResponse__CommandExecutionApprovalDecision = | "accept" @@ -18374,29 +20028,66 @@ export const McpServerElicitationRequestParams__McpElicitationSingleSelectEnumSc ], ); -export type PermissionsRequestApprovalParams__FileSystemSandboxEntry = { - readonly access: PermissionsRequestApprovalParams__FileSystemAccessMode; - readonly path: PermissionsRequestApprovalParams__FileSystemPath; -}; -export const PermissionsRequestApprovalParams__FileSystemSandboxEntry = Schema.Struct({ - access: PermissionsRequestApprovalParams__FileSystemAccessMode, - path: PermissionsRequestApprovalParams__FileSystemPath, -}); +export type PermissionsRequestApprovalParams__FileSystemPath = + | { readonly path: PermissionsRequestApprovalParams__LegacyAppPathString; readonly type: "path" } + | { readonly pattern: string; readonly type: "glob_pattern" } + | { + readonly type: "special"; + readonly value: PermissionsRequestApprovalParams__FileSystemSpecialPath; + }; +export const PermissionsRequestApprovalParams__FileSystemPath = Schema.Union( + [ + Schema.Struct({ + path: PermissionsRequestApprovalParams__LegacyAppPathString, + type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), + }).annotate({ title: "PathFileSystemPath" }), + Schema.Struct({ + pattern: Schema.String, + type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), + }).annotate({ title: "GlobPatternFileSystemPath" }), + Schema.Struct({ + type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), + value: PermissionsRequestApprovalParams__FileSystemSpecialPath, + }).annotate({ title: "SpecialFileSystemPath" }), + ], + { mode: "oneOf" }, +); -export type PermissionsRequestApprovalResponse__FileSystemSandboxEntry = { - readonly access: PermissionsRequestApprovalResponse__FileSystemAccessMode; - readonly path: PermissionsRequestApprovalResponse__FileSystemPath; -}; -export const PermissionsRequestApprovalResponse__FileSystemSandboxEntry = Schema.Struct({ - access: PermissionsRequestApprovalResponse__FileSystemAccessMode, - path: PermissionsRequestApprovalResponse__FileSystemPath, -}); +export type PermissionsRequestApprovalResponse__FileSystemPath = + | { + readonly path: PermissionsRequestApprovalResponse__LegacyAppPathString; + readonly type: "path"; + } + | { readonly pattern: string; readonly type: "glob_pattern" } + | { + readonly type: "special"; + readonly value: PermissionsRequestApprovalResponse__FileSystemSpecialPath; + }; +export const PermissionsRequestApprovalResponse__FileSystemPath = Schema.Union( + [ + Schema.Struct({ + path: PermissionsRequestApprovalResponse__LegacyAppPathString, + type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), + }).annotate({ title: "PathFileSystemPath" }), + Schema.Struct({ + pattern: Schema.String, + type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), + }).annotate({ title: "GlobPatternFileSystemPath" }), + Schema.Struct({ + type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), + value: PermissionsRequestApprovalResponse__FileSystemSpecialPath, + }).annotate({ title: "SpecialFileSystemPath" }), + ], + { mode: "oneOf" }, +); export type ServerNotification__AppInfo = { readonly appMetadata?: ServerNotification__AppMetadata | null; readonly branding?: ServerNotification__AppBranding | null; readonly description?: string | null; readonly distributionChannel?: string | null; + readonly iconAssets?: { readonly [x: string]: string } | null; + readonly iconDarkAssets?: { readonly [x: string]: string } | null; readonly id: string; readonly installUrl?: string | null; readonly isAccessible?: boolean; @@ -18412,6 +20103,12 @@ export const ServerNotification__AppInfo = Schema.Struct({ branding: Schema.optionalKey(Schema.Union([ServerNotification__AppBranding, Schema.Null])), description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), distributionChannel: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + iconAssets: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]), + ), + iconDarkAssets: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]), + ), id: Schema.String, installUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), isAccessible: Schema.optionalKey(Schema.Boolean.annotate({ default: false })), @@ -18431,13 +20128,15 @@ export const ServerNotification__AppInfo = Schema.Struct({ pluginDisplayNames: Schema.optionalKey(Schema.Array(Schema.String).annotate({ default: [] })), }).annotate({ description: "EXPERIMENTAL - app metadata returned by app-list APIs." }); -export type ServerNotification__FileSystemSandboxEntry = { - readonly access: ServerNotification__FileSystemAccessMode; - readonly path: ServerNotification__FileSystemPath; +export type ServerNotification__ExternalAgentConfigImportTypeResult = { + readonly failures: ReadonlyArray; + readonly itemType: ServerNotification__ExternalAgentConfigMigrationItemType; + readonly successes: ReadonlyArray; }; -export const ServerNotification__FileSystemSandboxEntry = Schema.Struct({ - access: ServerNotification__FileSystemAccessMode, - path: ServerNotification__FileSystemPath, +export const ServerNotification__ExternalAgentConfigImportTypeResult = Schema.Struct({ + failures: Schema.Array(ServerNotification__ExternalAgentConfigImportItemTypeFailure), + itemType: ServerNotification__ExternalAgentConfigMigrationItemType, + successes: Schema.Array(ServerNotification__ExternalAgentConfigImportItemTypeSuccess), }); export type ServerNotification__FuzzyFileSearchSessionUpdatedNotification = { @@ -18513,6 +20212,28 @@ export const ServerNotification__HookRunSummary = Schema.Struct({ statusMessage: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); +export type ServerNotification__FileSystemPath = + | { readonly path: ServerNotification__LegacyAppPathString; readonly type: "path" } + | { readonly pattern: string; readonly type: "glob_pattern" } + | { readonly type: "special"; readonly value: ServerNotification__FileSystemSpecialPath }; +export const ServerNotification__FileSystemPath = Schema.Union( + [ + Schema.Struct({ + path: ServerNotification__LegacyAppPathString, + type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), + }).annotate({ title: "PathFileSystemPath" }), + Schema.Struct({ + pattern: Schema.String, + type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), + }).annotate({ title: "GlobPatternFileSystemPath" }), + Schema.Struct({ + type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), + value: ServerNotification__FileSystemSpecialPath, + }).annotate({ title: "SpecialFileSystemPath" }), + ], + { mode: "oneOf" }, +); + export type ServerNotification__TurnError = { readonly additionalDetails?: string | null; readonly codexErrorInfo?: ServerNotification__CodexErrorInfo | null; @@ -18604,6 +20325,7 @@ export type ServerNotification__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: ServerNotification__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: ServerNotification__McpToolCallError | null; @@ -18650,13 +20372,15 @@ export type ServerNotification__ThreadItem = readonly action?: ServerNotification__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: ServerNotification__AbsolutePathBuf; + readonly path: ServerNotification__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -18719,10 +20443,7 @@ export const ServerNotification__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -18770,6 +20491,9 @@ export const ServerNotification__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([ServerNotification__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -18782,7 +20506,14 @@ export const ServerNotification__ThreadItem = Schema.Union( ), error: Schema.optionalKey(Schema.Union([ServerNotification__McpToolCallError, Schema.Null])), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([ServerNotification__McpToolCallResult, Schema.Null]), @@ -18876,13 +20607,32 @@ export const ServerNotification__ThreadItem = Schema.Union( action: Schema.optionalKey(Schema.Union([ServerNotification__WebSearchAction, Schema.Null])), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: ServerNotification__AbsolutePathBuf, + path: ServerNotification__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -18990,14 +20740,27 @@ export const ServerNotification__TurnPlanUpdatedNotification = Schema.Struct({ turnId: Schema.String, }); -export type ServerRequest__FileSystemSandboxEntry = { - readonly access: ServerRequest__FileSystemAccessMode; - readonly path: ServerRequest__FileSystemPath; -}; -export const ServerRequest__FileSystemSandboxEntry = Schema.Struct({ - access: ServerRequest__FileSystemAccessMode, - path: ServerRequest__FileSystemPath, -}); +export type ServerRequest__FileSystemPath = + | { readonly path: ServerRequest__LegacyAppPathString; readonly type: "path" } + | { readonly pattern: string; readonly type: "glob_pattern" } + | { readonly type: "special"; readonly value: ServerRequest__FileSystemSpecialPath }; +export const ServerRequest__FileSystemPath = Schema.Union( + [ + Schema.Struct({ + path: ServerRequest__LegacyAppPathString, + type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), + }).annotate({ title: "PathFileSystemPath" }), + Schema.Struct({ + pattern: Schema.String, + type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), + }).annotate({ title: "GlobPatternFileSystemPath" }), + Schema.Struct({ + type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), + value: ServerRequest__FileSystemSpecialPath, + }).annotate({ title: "SpecialFileSystemPath" }), + ], + { mode: "oneOf" }, +); export type ServerRequest__McpElicitationTitledMultiSelectEnumSchema = { readonly default?: ReadonlyArray | null; @@ -19077,7 +20840,8 @@ export type ServerRequest__CommandExecutionRequestApprovalParams = { readonly approvalId?: string | null; readonly command?: string | null; readonly commandActions?: ReadonlyArray | null; - readonly cwd?: ServerRequest__AbsolutePathBuf | null; + readonly cwd?: ServerRequest__LegacyAppPathString | null; + readonly environmentId?: string | null; readonly itemId: string; readonly networkApprovalContext?: ServerRequest__NetworkApprovalContext | null; readonly proposedExecpolicyAmendment?: ReadonlyArray | null; @@ -19112,10 +20876,16 @@ export const ServerRequest__CommandExecutionRequestApprovalParams = Schema.Struc ]), ), cwd: Schema.optionalKey( - Schema.Union([ServerRequest__AbsolutePathBuf, Schema.Null]).annotate({ + Schema.Union([ServerRequest__LegacyAppPathString, Schema.Null]).annotate({ description: "The command's working directory.", }), ), + environmentId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Environment in which the command will run." }), + Schema.Null, + ]), + ), itemId: Schema.String, networkApprovalContext: Schema.optionalKey( Schema.Union([ServerRequest__NetworkApprovalContext, Schema.Null]).annotate({ @@ -19157,12 +20927,21 @@ export const ServerRequest__CommandExecutionRequestApprovalParams = Schema.Struc }); export type ServerRequest__ToolRequestUserInputParams = { + readonly autoResolutionMs?: number | null; readonly itemId: string; readonly questions: ReadonlyArray; readonly threadId: string; readonly turnId: string; }; export const ServerRequest__ToolRequestUserInputParams = Schema.Struct({ + autoResolutionMs: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), itemId: Schema.String, questions: Schema.Array(ServerRequest__ToolRequestUserInputQuestion), threadId: Schema.String, @@ -19174,6 +20953,8 @@ export type V2AppListUpdatedNotification__AppInfo = { readonly branding?: V2AppListUpdatedNotification__AppBranding | null; readonly description?: string | null; readonly distributionChannel?: string | null; + readonly iconAssets?: { readonly [x: string]: string } | null; + readonly iconDarkAssets?: { readonly [x: string]: string } | null; readonly id: string; readonly installUrl?: string | null; readonly isAccessible?: boolean; @@ -19193,6 +20974,12 @@ export const V2AppListUpdatedNotification__AppInfo = Schema.Struct({ ), description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), distributionChannel: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + iconAssets: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]), + ), + iconDarkAssets: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]), + ), id: Schema.String, installUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), isAccessible: Schema.optionalKey(Schema.Boolean.annotate({ default: false })), @@ -19217,6 +21004,8 @@ export type V2AppsListResponse__AppInfo = { readonly branding?: V2AppsListResponse__AppBranding | null; readonly description?: string | null; readonly distributionChannel?: string | null; + readonly iconAssets?: { readonly [x: string]: string } | null; + readonly iconDarkAssets?: { readonly [x: string]: string } | null; readonly id: string; readonly installUrl?: string | null; readonly isAccessible?: boolean; @@ -19232,6 +21021,12 @@ export const V2AppsListResponse__AppInfo = Schema.Struct({ branding: Schema.optionalKey(Schema.Union([V2AppsListResponse__AppBranding, Schema.Null])), description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), distributionChannel: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + iconAssets: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]), + ), + iconDarkAssets: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]), + ), id: Schema.String, installUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), isAccessible: Schema.optionalKey(Schema.Boolean.annotate({ default: false })), @@ -19282,6 +21077,15 @@ export const V2ConfigReadResponse__ToolsV2 = Schema.Struct({ ), }); +export type V2ConfigRequirementsReadResponse__ModelsRequirements = { + readonly newThread?: V2ConfigRequirementsReadResponse__NewThreadModelDefaults | null; +}; +export const V2ConfigRequirementsReadResponse__ModelsRequirements = Schema.Struct({ + newThread: Schema.optionalKey( + Schema.Union([V2ConfigRequirementsReadResponse__NewThreadModelDefaults, Schema.Null]), + ), +}); + export type V2ConfigWriteResponse__ConfigLayerMetadata = { readonly name: V2ConfigWriteResponse__ConfigLayerSource; readonly version: string; @@ -19327,6 +21131,42 @@ export const V2ExternalAgentConfigDetectResponse__ExternalAgentConfigMigrationIt itemType: V2ExternalAgentConfigDetectResponse__ExternalAgentConfigMigrationItemType, }); +export type V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigImportTypeResult = + { + readonly failures: ReadonlyArray; + readonly itemType: V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigMigrationItemType; + readonly successes: ReadonlyArray; + }; +export const V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigImportTypeResult = + Schema.Struct({ + failures: Schema.Array( + V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigImportItemTypeFailure, + ), + itemType: + V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigMigrationItemType, + successes: Schema.Array( + V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigImportItemTypeSuccess, + ), + }); + +export type V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigImportHistory = { + readonly completedAtMs: number; + readonly failures: ReadonlyArray; + readonly importId: string; + readonly successes: ReadonlyArray; +}; +export const V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigImportHistory = + Schema.Struct({ + completedAtMs: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + failures: Schema.Array( + V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigImportItemTypeFailure, + ), + importId: Schema.String, + successes: Schema.Array( + V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigImportItemTypeSuccess, + ), + }); + export type V2ExternalAgentConfigImportParams__ExternalAgentConfigMigrationItem = { readonly cwd?: string | null; readonly description: string; @@ -19350,6 +21190,39 @@ export const V2ExternalAgentConfigImportParams__ExternalAgentConfigMigrationItem itemType: V2ExternalAgentConfigImportParams__ExternalAgentConfigMigrationItemType, }); +export type V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigImportTypeResult = { + readonly failures: ReadonlyArray; + readonly itemType: V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigMigrationItemType; + readonly successes: ReadonlyArray; +}; +export const V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigImportTypeResult = + Schema.Struct({ + failures: Schema.Array( + V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigImportItemTypeFailure, + ), + itemType: V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigMigrationItemType, + successes: Schema.Array( + V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigImportItemTypeSuccess, + ), + }); + +export type V2GetAccountRateLimitsResponse__RateLimitResetCreditsSummary = { + readonly availableCount: number; + readonly credits?: ReadonlyArray | null; +}; +export const V2GetAccountRateLimitsResponse__RateLimitResetCreditsSummary = Schema.Struct({ + availableCount: Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + credits: Schema.optionalKey( + Schema.Union([ + Schema.Array(V2GetAccountRateLimitsResponse__RateLimitResetCredit).annotate({ + description: + "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.", + }), + Schema.Null, + ]), + ), +}); + export type V2HookCompletedNotification__HookRunSummary = { readonly completedAt?: number | null; readonly displayOrder: number; @@ -19533,6 +21406,7 @@ export type V2ItemCompletedNotification__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ItemCompletedNotification__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ItemCompletedNotification__McpToolCallError | null; @@ -19581,13 +21455,15 @@ export type V2ItemCompletedNotification__ThreadItem = readonly action?: V2ItemCompletedNotification__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ItemCompletedNotification__AbsolutePathBuf; + readonly path: V2ItemCompletedNotification__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -19652,10 +21528,7 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -19703,6 +21576,9 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ItemCompletedNotification__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -19717,7 +21593,14 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union( Schema.Union([V2ItemCompletedNotification__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ItemCompletedNotification__McpToolCallResult, Schema.Null]), @@ -19814,13 +21697,32 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ItemCompletedNotification__AbsolutePathBuf, + path: V2ItemCompletedNotification__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -19855,25 +21757,61 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union( { mode: "oneOf" }, ); -export type V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSandboxEntry = { - readonly access: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemAccessMode; - readonly path: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemPath; -}; -export const V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSandboxEntry = - Schema.Struct({ - access: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemAccessMode, - path: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemPath, - }); +export type V2ItemGuardianApprovalReviewCompletedNotification__FileSystemPath = + | { + readonly path: V2ItemGuardianApprovalReviewCompletedNotification__LegacyAppPathString; + readonly type: "path"; + } + | { readonly pattern: string; readonly type: "glob_pattern" } + | { + readonly type: "special"; + readonly value: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSpecialPath; + }; +export const V2ItemGuardianApprovalReviewCompletedNotification__FileSystemPath = Schema.Union( + [ + Schema.Struct({ + path: V2ItemGuardianApprovalReviewCompletedNotification__LegacyAppPathString, + type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), + }).annotate({ title: "PathFileSystemPath" }), + Schema.Struct({ + pattern: Schema.String, + type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), + }).annotate({ title: "GlobPatternFileSystemPath" }), + Schema.Struct({ + type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), + value: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSpecialPath, + }).annotate({ title: "SpecialFileSystemPath" }), + ], + { mode: "oneOf" }, +); -export type V2ItemGuardianApprovalReviewStartedNotification__FileSystemSandboxEntry = { - readonly access: V2ItemGuardianApprovalReviewStartedNotification__FileSystemAccessMode; - readonly path: V2ItemGuardianApprovalReviewStartedNotification__FileSystemPath; -}; -export const V2ItemGuardianApprovalReviewStartedNotification__FileSystemSandboxEntry = - Schema.Struct({ - access: V2ItemGuardianApprovalReviewStartedNotification__FileSystemAccessMode, - path: V2ItemGuardianApprovalReviewStartedNotification__FileSystemPath, - }); +export type V2ItemGuardianApprovalReviewStartedNotification__FileSystemPath = + | { + readonly path: V2ItemGuardianApprovalReviewStartedNotification__LegacyAppPathString; + readonly type: "path"; + } + | { readonly pattern: string; readonly type: "glob_pattern" } + | { + readonly type: "special"; + readonly value: V2ItemGuardianApprovalReviewStartedNotification__FileSystemSpecialPath; + }; +export const V2ItemGuardianApprovalReviewStartedNotification__FileSystemPath = Schema.Union( + [ + Schema.Struct({ + path: V2ItemGuardianApprovalReviewStartedNotification__LegacyAppPathString, + type: Schema.Literal("path").annotate({ title: "PathFileSystemPathType" }), + }).annotate({ title: "PathFileSystemPath" }), + Schema.Struct({ + pattern: Schema.String, + type: Schema.Literal("glob_pattern").annotate({ title: "GlobPatternFileSystemPathType" }), + }).annotate({ title: "GlobPatternFileSystemPath" }), + Schema.Struct({ + type: Schema.Literal("special").annotate({ title: "SpecialFileSystemPathType" }), + value: V2ItemGuardianApprovalReviewStartedNotification__FileSystemSpecialPath, + }).annotate({ title: "SpecialFileSystemPath" }), + ], + { mode: "oneOf" }, +); export type V2ItemStartedNotification__ThreadItem = | { @@ -19921,6 +21859,7 @@ export type V2ItemStartedNotification__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ItemStartedNotification__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ItemStartedNotification__McpToolCallError | null; @@ -19967,13 +21906,15 @@ export type V2ItemStartedNotification__ThreadItem = readonly action?: V2ItemStartedNotification__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ItemStartedNotification__AbsolutePathBuf; + readonly path: V2ItemStartedNotification__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -20038,10 +21979,7 @@ export const V2ItemStartedNotification__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -20089,6 +22027,9 @@ export const V2ItemStartedNotification__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ItemStartedNotification__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -20103,7 +22044,14 @@ export const V2ItemStartedNotification__ThreadItem = Schema.Union( Schema.Union([V2ItemStartedNotification__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ItemStartedNotification__McpToolCallResult, Schema.Null]), @@ -20200,13 +22148,32 @@ export const V2ItemStartedNotification__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ItemStartedNotification__AbsolutePathBuf, + path: V2ItemStartedNotification__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -20405,6 +22372,19 @@ export const V2PluginReadResponse__PluginShareContext = Schema.Struct({ shareUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); +export type V2PluginReadResponse__ScheduledTaskSummary = { + readonly key: string; + readonly name: string; + readonly prompt: string; + readonly schedule: V2PluginReadResponse__ScheduledTaskSchedule; +}; +export const V2PluginReadResponse__ScheduledTaskSummary = Schema.Struct({ + key: Schema.String, + name: Schema.String, + prompt: Schema.String, + schedule: V2PluginReadResponse__ScheduledTaskSchedule, +}); + export type V2PluginShareListResponse__PluginShareContext = { readonly creatorAccountUserId?: string | null; readonly creatorName?: string | null; @@ -20502,6 +22482,7 @@ export type V2ReviewStartResponse__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ReviewStartResponse__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ReviewStartResponse__McpToolCallError | null; @@ -20548,13 +22529,15 @@ export type V2ReviewStartResponse__ThreadItem = readonly action?: V2ReviewStartResponse__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ReviewStartResponse__AbsolutePathBuf; + readonly path: V2ReviewStartResponse__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -20617,10 +22600,7 @@ export const V2ReviewStartResponse__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -20668,6 +22648,9 @@ export const V2ReviewStartResponse__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ReviewStartResponse__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -20682,7 +22665,14 @@ export const V2ReviewStartResponse__ThreadItem = Schema.Union( Schema.Union([V2ReviewStartResponse__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ReviewStartResponse__McpToolCallResult, Schema.Null]), @@ -20778,13 +22768,32 @@ export const V2ReviewStartResponse__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ReviewStartResponse__AbsolutePathBuf, + path: V2ReviewStartResponse__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -20909,6 +22918,7 @@ export type V2ThreadForkResponse__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ThreadForkResponse__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ThreadForkResponse__McpToolCallError | null; @@ -20955,13 +22965,15 @@ export type V2ThreadForkResponse__ThreadItem = readonly action?: V2ThreadForkResponse__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ThreadForkResponse__AbsolutePathBuf; + readonly path: V2ThreadForkResponse__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -21024,10 +23036,7 @@ export const V2ThreadForkResponse__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -21075,6 +23084,9 @@ export const V2ThreadForkResponse__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ThreadForkResponse__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -21089,7 +23101,14 @@ export const V2ThreadForkResponse__ThreadItem = Schema.Union( Schema.Union([V2ThreadForkResponse__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ThreadForkResponse__McpToolCallResult, Schema.Null]), @@ -21185,13 +23204,32 @@ export const V2ThreadForkResponse__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ThreadForkResponse__AbsolutePathBuf, + path: V2ThreadForkResponse__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -21285,6 +23323,7 @@ export type V2ThreadListResponse__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ThreadListResponse__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ThreadListResponse__McpToolCallError | null; @@ -21331,13 +23370,15 @@ export type V2ThreadListResponse__ThreadItem = readonly action?: V2ThreadListResponse__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ThreadListResponse__AbsolutePathBuf; + readonly path: V2ThreadListResponse__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -21400,10 +23441,7 @@ export const V2ThreadListResponse__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -21451,6 +23489,9 @@ export const V2ThreadListResponse__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ThreadListResponse__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -21465,7 +23506,14 @@ export const V2ThreadListResponse__ThreadItem = Schema.Union( Schema.Union([V2ThreadListResponse__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ThreadListResponse__McpToolCallResult, Schema.Null]), @@ -21561,13 +23609,32 @@ export const V2ThreadListResponse__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ThreadListResponse__AbsolutePathBuf, + path: V2ThreadListResponse__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -21661,6 +23728,7 @@ export type V2ThreadMetadataUpdateResponse__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ThreadMetadataUpdateResponse__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ThreadMetadataUpdateResponse__McpToolCallError | null; @@ -21709,13 +23777,15 @@ export type V2ThreadMetadataUpdateResponse__ThreadItem = readonly action?: V2ThreadMetadataUpdateResponse__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ThreadMetadataUpdateResponse__AbsolutePathBuf; + readonly path: V2ThreadMetadataUpdateResponse__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -21780,10 +23850,7 @@ export const V2ThreadMetadataUpdateResponse__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -21831,6 +23898,9 @@ export const V2ThreadMetadataUpdateResponse__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ThreadMetadataUpdateResponse__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -21845,7 +23915,14 @@ export const V2ThreadMetadataUpdateResponse__ThreadItem = Schema.Union( Schema.Union([V2ThreadMetadataUpdateResponse__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ThreadMetadataUpdateResponse__McpToolCallResult, Schema.Null]), @@ -21942,13 +24019,32 @@ export const V2ThreadMetadataUpdateResponse__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ThreadMetadataUpdateResponse__AbsolutePathBuf, + path: V2ThreadMetadataUpdateResponse__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -22042,6 +24138,7 @@ export type V2ThreadReadResponse__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ThreadReadResponse__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ThreadReadResponse__McpToolCallError | null; @@ -22088,13 +24185,15 @@ export type V2ThreadReadResponse__ThreadItem = readonly action?: V2ThreadReadResponse__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ThreadReadResponse__AbsolutePathBuf; + readonly path: V2ThreadReadResponse__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -22157,10 +24256,7 @@ export const V2ThreadReadResponse__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -22208,6 +24304,9 @@ export const V2ThreadReadResponse__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ThreadReadResponse__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -22222,7 +24321,14 @@ export const V2ThreadReadResponse__ThreadItem = Schema.Union( Schema.Union([V2ThreadReadResponse__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ThreadReadResponse__McpToolCallResult, Schema.Null]), @@ -22318,13 +24424,32 @@ export const V2ThreadReadResponse__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ThreadReadResponse__AbsolutePathBuf, + path: V2ThreadReadResponse__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -22426,6 +24551,7 @@ export type V2ThreadResumeResponse__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ThreadResumeResponse__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ThreadResumeResponse__McpToolCallError | null; @@ -22472,13 +24598,15 @@ export type V2ThreadResumeResponse__ThreadItem = readonly action?: V2ThreadResumeResponse__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ThreadResumeResponse__AbsolutePathBuf; + readonly path: V2ThreadResumeResponse__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -22541,10 +24669,7 @@ export const V2ThreadResumeResponse__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -22592,6 +24717,9 @@ export const V2ThreadResumeResponse__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ThreadResumeResponse__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -22606,7 +24734,14 @@ export const V2ThreadResumeResponse__ThreadItem = Schema.Union( Schema.Union([V2ThreadResumeResponse__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ThreadResumeResponse__McpToolCallResult, Schema.Null]), @@ -22702,13 +24837,32 @@ export const V2ThreadResumeResponse__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ThreadResumeResponse__AbsolutePathBuf, + path: V2ThreadResumeResponse__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -22802,6 +24956,7 @@ export type V2ThreadRollbackResponse__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ThreadRollbackResponse__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ThreadRollbackResponse__McpToolCallError | null; @@ -22848,13 +25003,15 @@ export type V2ThreadRollbackResponse__ThreadItem = readonly action?: V2ThreadRollbackResponse__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ThreadRollbackResponse__AbsolutePathBuf; + readonly path: V2ThreadRollbackResponse__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -22919,10 +25076,7 @@ export const V2ThreadRollbackResponse__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -22970,6 +25124,9 @@ export const V2ThreadRollbackResponse__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ThreadRollbackResponse__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -22984,7 +25141,14 @@ export const V2ThreadRollbackResponse__ThreadItem = Schema.Union( Schema.Union([V2ThreadRollbackResponse__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ThreadRollbackResponse__McpToolCallResult, Schema.Null]), @@ -23081,13 +25245,32 @@ export const V2ThreadRollbackResponse__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ThreadRollbackResponse__AbsolutePathBuf, + path: V2ThreadRollbackResponse__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -23190,6 +25373,7 @@ export type V2ThreadStartedNotification__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ThreadStartedNotification__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ThreadStartedNotification__McpToolCallError | null; @@ -23238,13 +25422,15 @@ export type V2ThreadStartedNotification__ThreadItem = readonly action?: V2ThreadStartedNotification__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ThreadStartedNotification__AbsolutePathBuf; + readonly path: V2ThreadStartedNotification__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -23309,10 +25495,7 @@ export const V2ThreadStartedNotification__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -23360,6 +25543,9 @@ export const V2ThreadStartedNotification__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ThreadStartedNotification__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -23374,7 +25560,14 @@ export const V2ThreadStartedNotification__ThreadItem = Schema.Union( Schema.Union([V2ThreadStartedNotification__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ThreadStartedNotification__McpToolCallResult, Schema.Null]), @@ -23471,13 +25664,32 @@ export const V2ThreadStartedNotification__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ThreadStartedNotification__AbsolutePathBuf, + path: V2ThreadStartedNotification__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -23571,6 +25783,7 @@ export type V2ThreadStartResponse__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ThreadStartResponse__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ThreadStartResponse__McpToolCallError | null; @@ -23617,13 +25830,15 @@ export type V2ThreadStartResponse__ThreadItem = readonly action?: V2ThreadStartResponse__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ThreadStartResponse__AbsolutePathBuf; + readonly path: V2ThreadStartResponse__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -23686,10 +25901,7 @@ export const V2ThreadStartResponse__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -23737,6 +25949,9 @@ export const V2ThreadStartResponse__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ThreadStartResponse__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -23751,7 +25966,14 @@ export const V2ThreadStartResponse__ThreadItem = Schema.Union( Schema.Union([V2ThreadStartResponse__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ThreadStartResponse__McpToolCallResult, Schema.Null]), @@ -23847,13 +26069,32 @@ export const V2ThreadStartResponse__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ThreadStartResponse__AbsolutePathBuf, + path: V2ThreadStartResponse__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -23947,6 +26188,7 @@ export type V2ThreadUnarchiveResponse__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2ThreadUnarchiveResponse__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2ThreadUnarchiveResponse__McpToolCallError | null; @@ -23993,13 +26235,15 @@ export type V2ThreadUnarchiveResponse__ThreadItem = readonly action?: V2ThreadUnarchiveResponse__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2ThreadUnarchiveResponse__AbsolutePathBuf; + readonly path: V2ThreadUnarchiveResponse__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -24064,10 +26308,7 @@ export const V2ThreadUnarchiveResponse__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -24115,6 +26356,9 @@ export const V2ThreadUnarchiveResponse__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2ThreadUnarchiveResponse__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -24129,7 +26373,14 @@ export const V2ThreadUnarchiveResponse__ThreadItem = Schema.Union( Schema.Union([V2ThreadUnarchiveResponse__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2ThreadUnarchiveResponse__McpToolCallResult, Schema.Null]), @@ -24226,13 +26477,32 @@ export const V2ThreadUnarchiveResponse__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2ThreadUnarchiveResponse__AbsolutePathBuf, + path: V2ThreadUnarchiveResponse__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -24326,6 +26596,7 @@ export type V2TurnCompletedNotification__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2TurnCompletedNotification__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2TurnCompletedNotification__McpToolCallError | null; @@ -24374,13 +26645,15 @@ export type V2TurnCompletedNotification__ThreadItem = readonly action?: V2TurnCompletedNotification__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2TurnCompletedNotification__AbsolutePathBuf; + readonly path: V2TurnCompletedNotification__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -24445,10 +26718,7 @@ export const V2TurnCompletedNotification__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -24496,6 +26766,9 @@ export const V2TurnCompletedNotification__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2TurnCompletedNotification__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -24510,7 +26783,14 @@ export const V2TurnCompletedNotification__ThreadItem = Schema.Union( Schema.Union([V2TurnCompletedNotification__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2TurnCompletedNotification__McpToolCallResult, Schema.Null]), @@ -24607,13 +26887,32 @@ export const V2TurnCompletedNotification__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2TurnCompletedNotification__AbsolutePathBuf, + path: V2TurnCompletedNotification__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -24707,6 +27006,7 @@ export type V2TurnStartedNotification__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2TurnStartedNotification__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2TurnStartedNotification__McpToolCallError | null; @@ -24753,13 +27053,15 @@ export type V2TurnStartedNotification__ThreadItem = readonly action?: V2TurnStartedNotification__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2TurnStartedNotification__AbsolutePathBuf; + readonly path: V2TurnStartedNotification__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -24824,10 +27126,7 @@ export const V2TurnStartedNotification__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -24875,6 +27174,9 @@ export const V2TurnStartedNotification__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2TurnStartedNotification__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -24889,7 +27191,14 @@ export const V2TurnStartedNotification__ThreadItem = Schema.Union( Schema.Union([V2TurnStartedNotification__McpToolCallError, Schema.Null]), ), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2TurnStartedNotification__McpToolCallResult, Schema.Null]), @@ -24986,13 +27295,32 @@ export const V2TurnStartedNotification__ThreadItem = Schema.Union( ), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2TurnStartedNotification__AbsolutePathBuf, + path: V2TurnStartedNotification__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -25086,6 +27414,7 @@ export type V2TurnStartResponse__ThreadItem = readonly type: "fileChange"; } | { + readonly appContext?: V2TurnStartResponse__McpToolCallAppContext | null; readonly arguments: unknown; readonly durationMs?: number | null; readonly error?: V2TurnStartResponse__McpToolCallError | null; @@ -25132,13 +27461,15 @@ export type V2TurnStartResponse__ThreadItem = readonly action?: V2TurnStartResponse__WebSearchAction | null; readonly id: string; readonly query: string; + readonly results?: ReadonlyArray | null; readonly type: "webSearch"; } | { readonly id: string; - readonly path: V2TurnStartResponse__AbsolutePathBuf; + readonly path: V2TurnStartResponse__LegacyAppPathString; readonly type: "imageView"; } + | { readonly durationMs: number; readonly id: string; readonly type: "sleep" } | { readonly id: string; readonly result: string; @@ -25201,10 +27532,7 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union( description: "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", }), - cwd: Schema.String.annotate({ - description: - "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - }), + cwd: Schema.String.annotate({ description: "The command's working directory." }), durationMs: Schema.optionalKey( Schema.Union([ Schema.Number.annotate({ @@ -25252,6 +27580,9 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union( type: Schema.Literal("fileChange").annotate({ title: "FileChangeThreadItemType" }), }).annotate({ title: "FileChangeThreadItem" }), Schema.Struct({ + appContext: Schema.optionalKey( + Schema.Union([V2TurnStartResponse__McpToolCallAppContext, Schema.Null]), + ), arguments: Schema.Unknown, durationMs: Schema.optionalKey( Schema.Union([ @@ -25264,7 +27595,14 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union( ), error: Schema.optionalKey(Schema.Union([V2TurnStartResponse__McpToolCallError, Schema.Null])), id: Schema.String, - mcpAppResourceUri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mcpAppResourceUri: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Deprecated: use `appContext.resourceUri` instead.", + }), + Schema.Null, + ]), + ), pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), result: Schema.optionalKey( Schema.Union([V2TurnStartResponse__McpToolCallResult, Schema.Null]), @@ -25358,13 +27696,32 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union( action: Schema.optionalKey(Schema.Union([V2TurnStartResponse__WebSearchAction, Schema.Null])), id: Schema.String, query: Schema.String, + results: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.Unknown).annotate({ + description: + "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + }), + Schema.Null, + ]), + ), type: Schema.Literal("webSearch").annotate({ title: "WebSearchThreadItemType" }), }).annotate({ title: "WebSearchThreadItem" }), Schema.Struct({ id: Schema.String, - path: V2TurnStartResponse__AbsolutePathBuf, + path: V2TurnStartResponse__LegacyAppPathString, type: Schema.Literal("imageView").annotate({ title: "ImageViewThreadItemType" }), }).annotate({ title: "ImageViewThreadItem" }), + Schema.Struct({ + durationMs: Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + id: Schema.String, + type: Schema.Literal("sleep").annotate({ title: "SleepThreadItemType" }), + }).annotate({ + title: "SleepThreadItem", + description: "Display item emitted by the interruptible `clock.sleep` tool.", + }), Schema.Struct({ id: Schema.String, result: Schema.String, @@ -25401,51 +27758,38 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union( export type ClientRequest__ExternalAgentConfigImportParams = { readonly migrationItems: ReadonlyArray; + readonly migrationSource?: string | null; + readonly source?: string | null; }; export const ClientRequest__ExternalAgentConfigImportParams = Schema.Struct({ migrationItems: Schema.Array(ClientRequest__ExternalAgentConfigMigrationItem), + migrationSource: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Migration-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source.", + }), + Schema.Null, + ]), + ), + source: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional identifier for the product that initiated the import.", + }), + Schema.Null, + ]), + ), }); -export type CommandExecutionRequestApprovalParams__AdditionalFileSystemPermissions = { - readonly entries?: ReadonlyArray | null; - readonly globScanMaxDepth?: number | null; - readonly read?: ReadonlyArray | null; - readonly write?: ReadonlyArray | null; +export type CommandExecutionRequestApprovalParams__FileSystemSandboxEntry = { + readonly access: CommandExecutionRequestApprovalParams__FileSystemAccessMode; + readonly path: CommandExecutionRequestApprovalParams__FileSystemPath; }; -export const CommandExecutionRequestApprovalParams__AdditionalFileSystemPermissions = Schema.Struct( - { - entries: Schema.optionalKey( - Schema.Union([ - Schema.Array(CommandExecutionRequestApprovalParams__FileSystemSandboxEntry), - Schema.Null, - ]), - ), - globScanMaxDepth: Schema.optionalKey( - Schema.Union([ - Schema.Number.annotate({ format: "uint" }) - .check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)), - Schema.Null, - ]), - ), - read: Schema.optionalKey( - Schema.Union([ - Schema.Array(CommandExecutionRequestApprovalParams__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), - write: Schema.optionalKey( - Schema.Union([ - Schema.Array(CommandExecutionRequestApprovalParams__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), - }, -); +export const CommandExecutionRequestApprovalParams__FileSystemSandboxEntry = Schema.Struct({ + access: CommandExecutionRequestApprovalParams__FileSystemAccessMode, + path: CommandExecutionRequestApprovalParams__FileSystemPath, +}); export type McpServerElicitationRequestParams__McpElicitationMultiSelectEnumSchema = | McpServerElicitationRequestParams__McpElicitationUntitledMultiSelectEnumSchema @@ -25455,82 +27799,22 @@ export const McpServerElicitationRequestParams__McpElicitationMultiSelectEnumSch McpServerElicitationRequestParams__McpElicitationTitledMultiSelectEnumSchema, ]); -export type PermissionsRequestApprovalParams__AdditionalFileSystemPermissions = { - readonly entries?: ReadonlyArray | null; - readonly globScanMaxDepth?: number | null; - readonly read?: ReadonlyArray | null; - readonly write?: ReadonlyArray | null; +export type PermissionsRequestApprovalParams__FileSystemSandboxEntry = { + readonly access: PermissionsRequestApprovalParams__FileSystemAccessMode; + readonly path: PermissionsRequestApprovalParams__FileSystemPath; }; -export const PermissionsRequestApprovalParams__AdditionalFileSystemPermissions = Schema.Struct({ - entries: Schema.optionalKey( - Schema.Union([ - Schema.Array(PermissionsRequestApprovalParams__FileSystemSandboxEntry), - Schema.Null, - ]), - ), - globScanMaxDepth: Schema.optionalKey( - Schema.Union([ - Schema.Number.annotate({ format: "uint" }) - .check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)), - Schema.Null, - ]), - ), - read: Schema.optionalKey( - Schema.Union([ - Schema.Array(PermissionsRequestApprovalParams__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), - write: Schema.optionalKey( - Schema.Union([ - Schema.Array(PermissionsRequestApprovalParams__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), +export const PermissionsRequestApprovalParams__FileSystemSandboxEntry = Schema.Struct({ + access: PermissionsRequestApprovalParams__FileSystemAccessMode, + path: PermissionsRequestApprovalParams__FileSystemPath, }); -export type PermissionsRequestApprovalResponse__AdditionalFileSystemPermissions = { - readonly entries?: ReadonlyArray | null; - readonly globScanMaxDepth?: number | null; - readonly read?: ReadonlyArray | null; - readonly write?: ReadonlyArray | null; +export type PermissionsRequestApprovalResponse__FileSystemSandboxEntry = { + readonly access: PermissionsRequestApprovalResponse__FileSystemAccessMode; + readonly path: PermissionsRequestApprovalResponse__FileSystemPath; }; -export const PermissionsRequestApprovalResponse__AdditionalFileSystemPermissions = Schema.Struct({ - entries: Schema.optionalKey( - Schema.Union([ - Schema.Array(PermissionsRequestApprovalResponse__FileSystemSandboxEntry), - Schema.Null, - ]), - ), - globScanMaxDepth: Schema.optionalKey( - Schema.Union([ - Schema.Number.annotate({ format: "uint" }) - .check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)), - Schema.Null, - ]), - ), - read: Schema.optionalKey( - Schema.Union([ - Schema.Array(PermissionsRequestApprovalResponse__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), - write: Schema.optionalKey( - Schema.Union([ - Schema.Array(PermissionsRequestApprovalResponse__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), +export const PermissionsRequestApprovalResponse__FileSystemSandboxEntry = Schema.Struct({ + access: PermissionsRequestApprovalResponse__FileSystemAccessMode, + path: PermissionsRequestApprovalResponse__FileSystemPath, }); export type ServerNotification__AppListUpdatedNotification = { @@ -25540,40 +27824,22 @@ export const ServerNotification__AppListUpdatedNotification = Schema.Struct({ data: Schema.Array(ServerNotification__AppInfo), }).annotate({ description: "EXPERIMENTAL - notification emitted when the app list changes." }); -export type ServerNotification__AdditionalFileSystemPermissions = { - readonly entries?: ReadonlyArray | null; - readonly globScanMaxDepth?: number | null; - readonly read?: ReadonlyArray | null; - readonly write?: ReadonlyArray | null; +export type ServerNotification__ExternalAgentConfigImportCompletedNotification = { + readonly importId: string; + readonly itemTypeResults: ReadonlyArray; }; -export const ServerNotification__AdditionalFileSystemPermissions = Schema.Struct({ - entries: Schema.optionalKey( - Schema.Union([Schema.Array(ServerNotification__FileSystemSandboxEntry), Schema.Null]), - ), - globScanMaxDepth: Schema.optionalKey( - Schema.Union([ - Schema.Number.annotate({ format: "uint" }) - .check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)), - Schema.Null, - ]), - ), - read: Schema.optionalKey( - Schema.Union([ - Schema.Array(ServerNotification__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), - write: Schema.optionalKey( - Schema.Union([ - Schema.Array(ServerNotification__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), +export const ServerNotification__ExternalAgentConfigImportCompletedNotification = Schema.Struct({ + importId: Schema.String, + itemTypeResults: Schema.Array(ServerNotification__ExternalAgentConfigImportTypeResult), +}); + +export type ServerNotification__ExternalAgentConfigImportProgressNotification = { + readonly importId: string; + readonly itemTypeResults: ReadonlyArray; +}; +export const ServerNotification__ExternalAgentConfigImportProgressNotification = Schema.Struct({ + importId: Schema.String, + itemTypeResults: Schema.Array(ServerNotification__ExternalAgentConfigImportTypeResult), }); export type ServerNotification__HookCompletedNotification = { @@ -25598,6 +27864,15 @@ export const ServerNotification__HookStartedNotification = Schema.Struct({ turnId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }); +export type ServerNotification__FileSystemSandboxEntry = { + readonly access: ServerNotification__FileSystemAccessMode; + readonly path: ServerNotification__FileSystemPath; +}; +export const ServerNotification__FileSystemSandboxEntry = Schema.Struct({ + access: ServerNotification__FileSystemAccessMode, + path: ServerNotification__FileSystemPath, +}); + export type ServerNotification__ErrorNotification = { readonly error: ServerNotification__TurnError; readonly threadId: string; @@ -25708,7 +27983,9 @@ export const ServerNotification__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(ServerNotification__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -25730,40 +28007,13 @@ export const ServerNotification__Turn = Schema.Struct({ status: ServerNotification__TurnStatus, }); -export type ServerRequest__AdditionalFileSystemPermissions = { - readonly entries?: ReadonlyArray | null; - readonly globScanMaxDepth?: number | null; - readonly read?: ReadonlyArray | null; - readonly write?: ReadonlyArray | null; +export type ServerRequest__FileSystemSandboxEntry = { + readonly access: ServerRequest__FileSystemAccessMode; + readonly path: ServerRequest__FileSystemPath; }; -export const ServerRequest__AdditionalFileSystemPermissions = Schema.Struct({ - entries: Schema.optionalKey( - Schema.Union([Schema.Array(ServerRequest__FileSystemSandboxEntry), Schema.Null]), - ), - globScanMaxDepth: Schema.optionalKey( - Schema.Union([ - Schema.Number.annotate({ format: "uint" }) - .check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)), - Schema.Null, - ]), - ), - read: Schema.optionalKey( - Schema.Union([ - Schema.Array(ServerRequest__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), - write: Schema.optionalKey( - Schema.Union([ - Schema.Array(ServerRequest__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), +export const ServerRequest__FileSystemSandboxEntry = Schema.Struct({ + access: ServerRequest__FileSystemAccessMode, + path: ServerRequest__FileSystemPath, }); export type ServerRequest__McpElicitationMultiSelectEnumSchema = @@ -25868,6 +28118,58 @@ export const V2ConfigReadResponse__Config = Schema.StructWithRest( [Schema.Record(Schema.String, Schema.Unknown)], ); +export type V2ConfigRequirementsReadResponse__ConfigRequirements = { + readonly allowAppshots?: boolean | null; + readonly allowManagedHooksOnly?: boolean | null; + readonly allowRemoteControl?: boolean | null; + readonly allowedApprovalPolicies?: ReadonlyArray | null; + readonly allowedPermissionProfiles?: { readonly [x: string]: boolean } | null; + readonly allowedSandboxModes?: ReadonlyArray | null; + readonly allowedWebSearchModes?: ReadonlyArray | null; + readonly allowedWindowsSandboxImplementations?: ReadonlyArray | null; + readonly computerUse?: V2ConfigRequirementsReadResponse__ComputerUseRequirements | null; + readonly defaultPermissions?: string | null; + readonly enforceResidency?: V2ConfigRequirementsReadResponse__ResidencyRequirement | null; + readonly featureRequirements?: { readonly [x: string]: boolean } | null; + readonly models?: V2ConfigRequirementsReadResponse__ModelsRequirements | null; +}; +export const V2ConfigRequirementsReadResponse__ConfigRequirements = Schema.Struct({ + allowAppshots: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), + allowManagedHooksOnly: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), + allowRemoteControl: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), + allowedApprovalPolicies: Schema.optionalKey( + Schema.Union([Schema.Array(V2ConfigRequirementsReadResponse__AskForApproval), Schema.Null]), + ), + allowedPermissionProfiles: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.Boolean), Schema.Null]), + ), + allowedSandboxModes: Schema.optionalKey( + Schema.Union([Schema.Array(V2ConfigRequirementsReadResponse__SandboxMode), Schema.Null]), + ), + allowedWebSearchModes: Schema.optionalKey( + Schema.Union([Schema.Array(V2ConfigRequirementsReadResponse__WebSearchMode), Schema.Null]), + ), + allowedWindowsSandboxImplementations: Schema.optionalKey( + Schema.Union([ + Schema.Array(V2ConfigRequirementsReadResponse__WindowsSandboxSetupMode), + Schema.Null, + ]), + ), + computerUse: Schema.optionalKey( + Schema.Union([V2ConfigRequirementsReadResponse__ComputerUseRequirements, Schema.Null]), + ), + defaultPermissions: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + enforceResidency: Schema.optionalKey( + Schema.Union([V2ConfigRequirementsReadResponse__ResidencyRequirement, Schema.Null]), + ), + featureRequirements: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.Boolean), Schema.Null]), + ), + models: Schema.optionalKey( + Schema.Union([V2ConfigRequirementsReadResponse__ModelsRequirements, Schema.Null]), + ), +}); + export type V2ConfigWriteResponse__OverriddenMetadata = { readonly effectiveValue: unknown; readonly message: string; @@ -25879,84 +28181,24 @@ export const V2ConfigWriteResponse__OverriddenMetadata = Schema.Struct({ overridingLayer: V2ConfigWriteResponse__ConfigLayerMetadata, }); -export type V2ItemGuardianApprovalReviewCompletedNotification__AdditionalFileSystemPermissions = { - readonly entries?: ReadonlyArray | null; - readonly globScanMaxDepth?: number | null; - readonly read?: ReadonlyArray | null; - readonly write?: ReadonlyArray | null; +export type V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSandboxEntry = { + readonly access: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemAccessMode; + readonly path: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemPath; }; -export const V2ItemGuardianApprovalReviewCompletedNotification__AdditionalFileSystemPermissions = +export const V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSandboxEntry = Schema.Struct({ - entries: Schema.optionalKey( - Schema.Union([ - Schema.Array(V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSandboxEntry), - Schema.Null, - ]), - ), - globScanMaxDepth: Schema.optionalKey( - Schema.Union([ - Schema.Number.annotate({ format: "uint" }) - .check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)), - Schema.Null, - ]), - ), - read: Schema.optionalKey( - Schema.Union([ - Schema.Array(V2ItemGuardianApprovalReviewCompletedNotification__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), - write: Schema.optionalKey( - Schema.Union([ - Schema.Array(V2ItemGuardianApprovalReviewCompletedNotification__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), + access: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemAccessMode, + path: V2ItemGuardianApprovalReviewCompletedNotification__FileSystemPath, }); -export type V2ItemGuardianApprovalReviewStartedNotification__AdditionalFileSystemPermissions = { - readonly entries?: ReadonlyArray | null; - readonly globScanMaxDepth?: number | null; - readonly read?: ReadonlyArray | null; - readonly write?: ReadonlyArray | null; +export type V2ItemGuardianApprovalReviewStartedNotification__FileSystemSandboxEntry = { + readonly access: V2ItemGuardianApprovalReviewStartedNotification__FileSystemAccessMode; + readonly path: V2ItemGuardianApprovalReviewStartedNotification__FileSystemPath; }; -export const V2ItemGuardianApprovalReviewStartedNotification__AdditionalFileSystemPermissions = +export const V2ItemGuardianApprovalReviewStartedNotification__FileSystemSandboxEntry = Schema.Struct({ - entries: Schema.optionalKey( - Schema.Union([ - Schema.Array(V2ItemGuardianApprovalReviewStartedNotification__FileSystemSandboxEntry), - Schema.Null, - ]), - ), - globScanMaxDepth: Schema.optionalKey( - Schema.Union([ - Schema.Number.annotate({ format: "uint" }) - .check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)), - Schema.Null, - ]), - ), - read: Schema.optionalKey( - Schema.Union([ - Schema.Array(V2ItemGuardianApprovalReviewStartedNotification__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), - write: Schema.optionalKey( - Schema.Union([ - Schema.Array(V2ItemGuardianApprovalReviewStartedNotification__AbsolutePathBuf).annotate({ - description: "This will be removed in favor of `entries`.", - }), - Schema.Null, - ]), - ), + access: V2ItemGuardianApprovalReviewStartedNotification__FileSystemAccessMode, + path: V2ItemGuardianApprovalReviewStartedNotification__FileSystemPath, }); export type V2PluginInstalledResponse__PluginSummary = { @@ -25965,14 +28207,17 @@ export type V2PluginInstalledResponse__PluginSummary = { readonly enabled: boolean; readonly id: string; readonly installPolicy: V2PluginInstalledResponse__PluginInstallPolicy; + readonly installPolicySource?: V2PluginInstalledResponse__PluginInstallPolicySource | null; readonly installed: boolean; readonly interface?: V2PluginInstalledResponse__PluginInterface | null; readonly keywords?: ReadonlyArray; readonly localVersion?: string | null; + readonly mustShowInstallationInterstitial?: boolean | null; readonly name: string; readonly remotePluginId?: string | null; readonly shareContext?: V2PluginInstalledResponse__PluginShareContext | null; readonly source: V2PluginInstalledResponse__PluginSource; + readonly version?: string | null; }; export const V2PluginInstalledResponse__PluginSummary = Schema.Struct({ authPolicy: V2PluginInstalledResponse__PluginAuthPolicy, @@ -25985,6 +28230,9 @@ export const V2PluginInstalledResponse__PluginSummary = Schema.Struct({ enabled: Schema.Boolean, id: Schema.String, installPolicy: V2PluginInstalledResponse__PluginInstallPolicy, + installPolicySource: Schema.optionalKey( + Schema.Union([V2PluginInstalledResponse__PluginInstallPolicySource, Schema.Null]), + ), installed: Schema.Boolean, interface: Schema.optionalKey( Schema.Union([V2PluginInstalledResponse__PluginInterface, Schema.Null]), @@ -25998,6 +28246,7 @@ export const V2PluginInstalledResponse__PluginSummary = Schema.Struct({ Schema.Null, ]), ), + mustShowInstallationInterstitial: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), name: Schema.String, remotePluginId: Schema.optionalKey( Schema.Union([ @@ -26011,6 +28260,14 @@ export const V2PluginInstalledResponse__PluginSummary = Schema.Struct({ }), ), source: V2PluginInstalledResponse__PluginSource, + version: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Version advertised by the remote marketplace backend when available.", + }), + Schema.Null, + ]), + ), }); export type V2PluginListResponse__PluginSummary = { @@ -26019,14 +28276,17 @@ export type V2PluginListResponse__PluginSummary = { readonly enabled: boolean; readonly id: string; readonly installPolicy: V2PluginListResponse__PluginInstallPolicy; + readonly installPolicySource?: V2PluginListResponse__PluginInstallPolicySource | null; readonly installed: boolean; readonly interface?: V2PluginListResponse__PluginInterface | null; readonly keywords?: ReadonlyArray; readonly localVersion?: string | null; + readonly mustShowInstallationInterstitial?: boolean | null; readonly name: string; readonly remotePluginId?: string | null; readonly shareContext?: V2PluginListResponse__PluginShareContext | null; readonly source: V2PluginListResponse__PluginSource; + readonly version?: string | null; }; export const V2PluginListResponse__PluginSummary = Schema.Struct({ authPolicy: V2PluginListResponse__PluginAuthPolicy, @@ -26039,6 +28299,9 @@ export const V2PluginListResponse__PluginSummary = Schema.Struct({ enabled: Schema.Boolean, id: Schema.String, installPolicy: V2PluginListResponse__PluginInstallPolicy, + installPolicySource: Schema.optionalKey( + Schema.Union([V2PluginListResponse__PluginInstallPolicySource, Schema.Null]), + ), installed: Schema.Boolean, interface: Schema.optionalKey(Schema.Union([V2PluginListResponse__PluginInterface, Schema.Null])), keywords: Schema.optionalKey(Schema.Array(Schema.String).annotate({ default: [] })), @@ -26050,6 +28313,7 @@ export const V2PluginListResponse__PluginSummary = Schema.Struct({ Schema.Null, ]), ), + mustShowInstallationInterstitial: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), name: Schema.String, remotePluginId: Schema.optionalKey( Schema.Union([ @@ -26063,6 +28327,14 @@ export const V2PluginListResponse__PluginSummary = Schema.Struct({ }), ), source: V2PluginListResponse__PluginSource, + version: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Version advertised by the remote marketplace backend when available.", + }), + Schema.Null, + ]), + ), }); export type V2PluginReadResponse__PluginSummary = { @@ -26071,14 +28343,17 @@ export type V2PluginReadResponse__PluginSummary = { readonly enabled: boolean; readonly id: string; readonly installPolicy: V2PluginReadResponse__PluginInstallPolicy; + readonly installPolicySource?: V2PluginReadResponse__PluginInstallPolicySource | null; readonly installed: boolean; readonly interface?: V2PluginReadResponse__PluginInterface | null; readonly keywords?: ReadonlyArray; readonly localVersion?: string | null; + readonly mustShowInstallationInterstitial?: boolean | null; readonly name: string; readonly remotePluginId?: string | null; readonly shareContext?: V2PluginReadResponse__PluginShareContext | null; readonly source: V2PluginReadResponse__PluginSource; + readonly version?: string | null; }; export const V2PluginReadResponse__PluginSummary = Schema.Struct({ authPolicy: V2PluginReadResponse__PluginAuthPolicy, @@ -26091,6 +28366,9 @@ export const V2PluginReadResponse__PluginSummary = Schema.Struct({ enabled: Schema.Boolean, id: Schema.String, installPolicy: V2PluginReadResponse__PluginInstallPolicy, + installPolicySource: Schema.optionalKey( + Schema.Union([V2PluginReadResponse__PluginInstallPolicySource, Schema.Null]), + ), installed: Schema.Boolean, interface: Schema.optionalKey(Schema.Union([V2PluginReadResponse__PluginInterface, Schema.Null])), keywords: Schema.optionalKey(Schema.Array(Schema.String).annotate({ default: [] })), @@ -26102,6 +28380,7 @@ export const V2PluginReadResponse__PluginSummary = Schema.Struct({ Schema.Null, ]), ), + mustShowInstallationInterstitial: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), name: Schema.String, remotePluginId: Schema.optionalKey( Schema.Union([ @@ -26115,6 +28394,14 @@ export const V2PluginReadResponse__PluginSummary = Schema.Struct({ }), ), source: V2PluginReadResponse__PluginSource, + version: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Version advertised by the remote marketplace backend when available.", + }), + Schema.Null, + ]), + ), }); export type V2PluginShareListResponse__PluginSummary = { @@ -26123,14 +28410,17 @@ export type V2PluginShareListResponse__PluginSummary = { readonly enabled: boolean; readonly id: string; readonly installPolicy: V2PluginShareListResponse__PluginInstallPolicy; + readonly installPolicySource?: V2PluginShareListResponse__PluginInstallPolicySource | null; readonly installed: boolean; readonly interface?: V2PluginShareListResponse__PluginInterface | null; readonly keywords?: ReadonlyArray; readonly localVersion?: string | null; + readonly mustShowInstallationInterstitial?: boolean | null; readonly name: string; readonly remotePluginId?: string | null; readonly shareContext?: V2PluginShareListResponse__PluginShareContext | null; readonly source: V2PluginShareListResponse__PluginSource; + readonly version?: string | null; }; export const V2PluginShareListResponse__PluginSummary = Schema.Struct({ authPolicy: V2PluginShareListResponse__PluginAuthPolicy, @@ -26143,6 +28433,9 @@ export const V2PluginShareListResponse__PluginSummary = Schema.Struct({ enabled: Schema.Boolean, id: Schema.String, installPolicy: V2PluginShareListResponse__PluginInstallPolicy, + installPolicySource: Schema.optionalKey( + Schema.Union([V2PluginShareListResponse__PluginInstallPolicySource, Schema.Null]), + ), installed: Schema.Boolean, interface: Schema.optionalKey( Schema.Union([V2PluginShareListResponse__PluginInterface, Schema.Null]), @@ -26156,6 +28449,7 @@ export const V2PluginShareListResponse__PluginSummary = Schema.Struct({ Schema.Null, ]), ), + mustShowInstallationInterstitial: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), name: Schema.String, remotePluginId: Schema.optionalKey( Schema.Union([ @@ -26169,12 +28463,21 @@ export const V2PluginShareListResponse__PluginSummary = Schema.Struct({ }), ), source: V2PluginShareListResponse__PluginSource, + version: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Version advertised by the remote marketplace backend when available.", + }), + Schema.Null, + ]), + ), }); export type V2RawResponseItemCompletedNotification__ResponseItem = | { readonly content: ReadonlyArray; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly phase?: V2RawResponseItemCompletedNotification__MessagePhase | null; readonly role: string; readonly type: "message"; @@ -26182,12 +28485,16 @@ export type V2RawResponseItemCompletedNotification__ResponseItem = | { readonly author: string; readonly content: ReadonlyArray; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly recipient: string; readonly type: "agent_message"; } | { readonly content?: ReadonlyArray | null; readonly encrypted_content?: string | null; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly summary: ReadonlyArray; readonly type: "reasoning"; } @@ -26195,6 +28502,7 @@ export type V2RawResponseItemCompletedNotification__ResponseItem = readonly action: V2RawResponseItemCompletedNotification__LocalShellAction; readonly call_id?: string | null; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly status: V2RawResponseItemCompletedNotification__LocalShellStatus; readonly type: "local_shell_call"; } @@ -26202,6 +28510,7 @@ export type V2RawResponseItemCompletedNotification__ResponseItem = readonly arguments: string; readonly call_id: string; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly name: string; readonly namespace?: string | null; readonly type: "function_call"; @@ -26211,11 +28520,14 @@ export type V2RawResponseItemCompletedNotification__ResponseItem = readonly call_id?: string | null; readonly execution: string; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly status?: string | null; readonly type: "tool_search_call"; } | { readonly call_id: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly output: V2RawResponseItemCompletedNotification__FunctionCallOutputBody; readonly type: "function_call_output"; } @@ -26223,12 +28535,16 @@ export type V2RawResponseItemCompletedNotification__ResponseItem = readonly call_id: string; readonly id?: string | null; readonly input: string; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly name: string; + readonly namespace?: string | null; readonly status?: string | null; readonly type: "custom_tool_call"; } | { readonly call_id: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly name?: string | null; readonly output: V2RawResponseItemCompletedNotification__FunctionCallOutputBody; readonly type: "custom_tool_call_output"; @@ -26236,6 +28552,8 @@ export type V2RawResponseItemCompletedNotification__ResponseItem = | { readonly call_id?: string | null; readonly execution: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly status: string; readonly tools: ReadonlyArray; readonly type: "tool_search_output"; @@ -26243,26 +28561,42 @@ export type V2RawResponseItemCompletedNotification__ResponseItem = | { readonly action?: V2RawResponseItemCompletedNotification__ResponsesApiWebSearchAction | null; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly status?: string | null; readonly type: "web_search_call"; } | { - readonly id: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; readonly result: string; readonly revised_prompt?: string | null; readonly status: string; readonly type: "image_generation_call"; } - | { readonly encrypted_content: string; readonly type: "compaction" } + | { + readonly encrypted_content: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; + readonly type: "compaction"; + } | { readonly type: "compaction_trigger" } - | { readonly encrypted_content?: string | null; readonly type: "context_compaction" } + | { + readonly encrypted_content?: string | null; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough | null; + readonly type: "context_compaction"; + } | { readonly type: "other" }; export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union( [ Schema.Struct({ content: Schema.Array(V2RawResponseItemCompletedNotification__ContentItem), - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), ), phase: Schema.optionalKey( Schema.Union([V2RawResponseItemCompletedNotification__MessagePhase, Schema.Null]), @@ -26273,6 +28607,13 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union Schema.Struct({ author: Schema.String, content: Schema.Array(V2RawResponseItemCompletedNotification__AgentMessageInputContent), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), + ), recipient: Schema.String, type: Schema.Literal("agent_message").annotate({ title: "AgentMessageResponseItemType" }), }).annotate({ title: "AgentMessageResponseItem" }), @@ -26284,6 +28625,13 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union ]), ), encrypted_content: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), + ), summary: Schema.Array(V2RawResponseItemCompletedNotification__ReasoningItemReasoningSummary), type: Schema.Literal("reasoning").annotate({ title: "ReasoningResponseItemType" }), }).annotate({ title: "ReasoningResponseItem" }), @@ -26299,11 +28647,16 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union Schema.Union([ Schema.String.annotate({ description: "Legacy id field retained for compatibility with older payloads.", - writeOnly: true, }), Schema.Null, ]), ), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), + ), status: V2RawResponseItemCompletedNotification__LocalShellStatus, type: Schema.Literal("local_shell_call").annotate({ title: "LocalShellCallResponseItemType", @@ -26312,8 +28665,12 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union Schema.Struct({ arguments: Schema.String, call_id: Schema.String, - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), ), name: Schema.String, namespace: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), @@ -26323,8 +28680,12 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union arguments: Schema.Unknown, call_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), execution: Schema.String, - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), ), status: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.Literal("tool_search_call").annotate({ @@ -26333,6 +28694,13 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union }).annotate({ title: "ToolSearchCallResponseItem" }), Schema.Struct({ call_id: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), + ), output: V2RawResponseItemCompletedNotification__FunctionCallOutputBody, type: Schema.Literal("function_call_output").annotate({ title: "FunctionCallOutputResponseItemType", @@ -26340,11 +28708,16 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union }).annotate({ title: "FunctionCallOutputResponseItem" }), Schema.Struct({ call_id: Schema.String, - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), - ), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), input: Schema.String, + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), + ), name: Schema.String, + namespace: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), status: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.Literal("custom_tool_call").annotate({ title: "CustomToolCallResponseItemType", @@ -26352,6 +28725,13 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union }).annotate({ title: "CustomToolCallResponseItem" }), Schema.Struct({ call_id: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), + ), name: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), output: V2RawResponseItemCompletedNotification__FunctionCallOutputBody, type: Schema.Literal("custom_tool_call_output").annotate({ @@ -26361,6 +28741,13 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union Schema.Struct({ call_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), execution: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), + ), status: Schema.String, tools: Schema.Array(Schema.Unknown), type: Schema.Literal("tool_search_output").annotate({ @@ -26374,14 +28761,24 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union Schema.Null, ]), ), - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), ), status: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.Literal("web_search_call").annotate({ title: "WebSearchCallResponseItemType" }), }).annotate({ title: "WebSearchCallResponseItem" }), Schema.Struct({ - id: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), + ), result: Schema.String, revised_prompt: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), status: Schema.String, @@ -26391,6 +28788,13 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union }).annotate({ title: "ImageGenerationCallResponseItem" }), Schema.Struct({ encrypted_content: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), + ), type: Schema.Literal("compaction").annotate({ title: "CompactionResponseItemType" }), }).annotate({ title: "CompactionResponseItem" }), Schema.Struct({ @@ -26400,6 +28804,13 @@ export const V2RawResponseItemCompletedNotification__ResponseItem = Schema.Union }).annotate({ title: "CompactionTriggerResponseItem" }), Schema.Struct({ encrypted_content: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ + V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough, + Schema.Null, + ]), + ), type: Schema.Literal("context_compaction").annotate({ title: "ContextCompactionResponseItemType", }), @@ -26445,7 +28856,9 @@ export const V2ReviewStartResponse__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2ReviewStartResponse__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -26512,7 +28925,9 @@ export const V2ThreadForkResponse__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2ThreadForkResponse__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -26568,7 +28983,9 @@ export const V2ThreadListResponse__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2ThreadListResponse__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -26624,7 +29041,9 @@ export const V2ThreadMetadataUpdateResponse__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2ThreadMetadataUpdateResponse__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -26680,7 +29099,9 @@ export const V2ThreadReadResponse__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2ThreadReadResponse__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -26736,7 +29157,9 @@ export const V2ThreadResumeResponse__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2ThreadResumeResponse__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -26792,7 +29215,9 @@ export const V2ThreadRollbackResponse__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2ThreadRollbackResponse__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -26885,7 +29310,9 @@ export const V2ThreadStartedNotification__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2ThreadStartedNotification__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -26941,7 +29368,9 @@ export const V2ThreadStartResponse__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2ThreadStartResponse__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -26997,7 +29426,9 @@ export const V2ThreadUnarchiveResponse__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2ThreadUnarchiveResponse__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -27053,7 +29484,9 @@ export const V2TurnCompletedNotification__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2TurnCompletedNotification__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -27109,7 +29542,9 @@ export const V2TurnStartedNotification__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2TurnStartedNotification__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -27165,7 +29600,9 @@ export const V2TurnStartResponse__Turn = Schema.Struct({ description: "Only populated when the Turn's status is failed.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + }), items: Schema.Array(V2TurnStartResponse__ThreadItem).annotate({ description: "Thread items currently included in this turn payload.", }), @@ -27187,6 +29624,47 @@ export const V2TurnStartResponse__Turn = Schema.Struct({ status: V2TurnStartResponse__TurnStatus, }); +export type CommandExecutionRequestApprovalParams__AdditionalFileSystemPermissions = { + readonly entries?: ReadonlyArray | null; + readonly globScanMaxDepth?: number | null; + readonly read?: ReadonlyArray | null; + readonly write?: ReadonlyArray | null; +}; +export const CommandExecutionRequestApprovalParams__AdditionalFileSystemPermissions = Schema.Struct( + { + entries: Schema.optionalKey( + Schema.Union([ + Schema.Array(CommandExecutionRequestApprovalParams__FileSystemSandboxEntry), + Schema.Null, + ]), + ), + globScanMaxDepth: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "uint" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)), + Schema.Null, + ]), + ), + read: Schema.optionalKey( + Schema.Union([ + Schema.Array(CommandExecutionRequestApprovalParams__LegacyAppPathString).annotate({ + description: "This will be removed in favor of `entries`.", + }), + Schema.Null, + ]), + ), + write: Schema.optionalKey( + Schema.Union([ + Schema.Array(CommandExecutionRequestApprovalParams__LegacyAppPathString).annotate({ + description: "This will be removed in favor of `entries`.", + }), + Schema.Null, + ]), + ), + }, +); + export type McpServerElicitationRequestParams__McpElicitationEnumSchema = | McpServerElicitationRequestParams__McpElicitationSingleSelectEnumSchema | McpServerElicitationRequestParams__McpElicitationMultiSelectEnumSchema @@ -27197,45 +29675,117 @@ export const McpServerElicitationRequestParams__McpElicitationEnumSchema = Schem McpServerElicitationRequestParams__McpElicitationLegacyTitledEnumSchema, ]); -export type PermissionsRequestApprovalParams__RequestPermissionProfile = { - readonly fileSystem?: PermissionsRequestApprovalParams__AdditionalFileSystemPermissions | null; - readonly network?: PermissionsRequestApprovalParams__AdditionalNetworkPermissions | null; +export type PermissionsRequestApprovalParams__AdditionalFileSystemPermissions = { + readonly entries?: ReadonlyArray | null; + readonly globScanMaxDepth?: number | null; + readonly read?: ReadonlyArray | null; + readonly write?: ReadonlyArray | null; }; -export const PermissionsRequestApprovalParams__RequestPermissionProfile = Schema.Struct({ - fileSystem: Schema.optionalKey( - Schema.Union([PermissionsRequestApprovalParams__AdditionalFileSystemPermissions, Schema.Null]), +export const PermissionsRequestApprovalParams__AdditionalFileSystemPermissions = Schema.Struct({ + entries: Schema.optionalKey( + Schema.Union([ + Schema.Array(PermissionsRequestApprovalParams__FileSystemSandboxEntry), + Schema.Null, + ]), ), - network: Schema.optionalKey( - Schema.Union([PermissionsRequestApprovalParams__AdditionalNetworkPermissions, Schema.Null]), + globScanMaxDepth: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "uint" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)), + Schema.Null, + ]), + ), + read: Schema.optionalKey( + Schema.Union([ + Schema.Array(PermissionsRequestApprovalParams__LegacyAppPathString).annotate({ + description: "This will be removed in favor of `entries`.", + }), + Schema.Null, + ]), + ), + write: Schema.optionalKey( + Schema.Union([ + Schema.Array(PermissionsRequestApprovalParams__LegacyAppPathString).annotate({ + description: "This will be removed in favor of `entries`.", + }), + Schema.Null, + ]), ), }); -export type PermissionsRequestApprovalResponse__GrantedPermissionProfile = { - readonly fileSystem?: PermissionsRequestApprovalResponse__AdditionalFileSystemPermissions | null; - readonly network?: PermissionsRequestApprovalResponse__AdditionalNetworkPermissions | null; +export type PermissionsRequestApprovalResponse__AdditionalFileSystemPermissions = { + readonly entries?: ReadonlyArray | null; + readonly globScanMaxDepth?: number | null; + readonly read?: ReadonlyArray | null; + readonly write?: ReadonlyArray | null; }; -export const PermissionsRequestApprovalResponse__GrantedPermissionProfile = Schema.Struct({ - fileSystem: Schema.optionalKey( +export const PermissionsRequestApprovalResponse__AdditionalFileSystemPermissions = Schema.Struct({ + entries: Schema.optionalKey( Schema.Union([ - PermissionsRequestApprovalResponse__AdditionalFileSystemPermissions, + Schema.Array(PermissionsRequestApprovalResponse__FileSystemSandboxEntry), Schema.Null, ]), ), - network: Schema.optionalKey( - Schema.Union([PermissionsRequestApprovalResponse__AdditionalNetworkPermissions, Schema.Null]), + globScanMaxDepth: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "uint" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)), + Schema.Null, + ]), + ), + read: Schema.optionalKey( + Schema.Union([ + Schema.Array(PermissionsRequestApprovalResponse__LegacyAppPathString).annotate({ + description: "This will be removed in favor of `entries`.", + }), + Schema.Null, + ]), + ), + write: Schema.optionalKey( + Schema.Union([ + Schema.Array(PermissionsRequestApprovalResponse__LegacyAppPathString).annotate({ + description: "This will be removed in favor of `entries`.", + }), + Schema.Null, + ]), ), }); -export type ServerNotification__RequestPermissionProfile = { - readonly fileSystem?: ServerNotification__AdditionalFileSystemPermissions | null; - readonly network?: ServerNotification__AdditionalNetworkPermissions | null; +export type ServerNotification__AdditionalFileSystemPermissions = { + readonly entries?: ReadonlyArray | null; + readonly globScanMaxDepth?: number | null; + readonly read?: ReadonlyArray | null; + readonly write?: ReadonlyArray | null; }; -export const ServerNotification__RequestPermissionProfile = Schema.Struct({ - fileSystem: Schema.optionalKey( - Schema.Union([ServerNotification__AdditionalFileSystemPermissions, Schema.Null]), +export const ServerNotification__AdditionalFileSystemPermissions = Schema.Struct({ + entries: Schema.optionalKey( + Schema.Union([Schema.Array(ServerNotification__FileSystemSandboxEntry), Schema.Null]), ), - network: Schema.optionalKey( - Schema.Union([ServerNotification__AdditionalNetworkPermissions, Schema.Null]), + globScanMaxDepth: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "uint" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)), + Schema.Null, + ]), + ), + read: Schema.optionalKey( + Schema.Union([ + Schema.Array(ServerNotification__LegacyAppPathString).annotate({ + description: "This will be removed in favor of `entries`.", + }), + Schema.Null, + ]), + ), + write: Schema.optionalKey( + Schema.Union([ + Schema.Array(ServerNotification__LegacyAppPathString).annotate({ + description: "This will be removed in favor of `entries`.", + }), + Schema.Null, + ]), ), }); @@ -27263,6 +29813,7 @@ export type ServerNotification__Thread = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -27329,7 +29880,9 @@ export const ServerNotification__Thread = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -27357,6 +29910,15 @@ export const ServerNotification__Thread = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -27423,16 +29985,39 @@ export const ServerNotification__TurnStartedNotification = Schema.Struct({ turn: ServerNotification__Turn, }); -export type ServerRequest__RequestPermissionProfile = { - readonly fileSystem?: ServerRequest__AdditionalFileSystemPermissions | null; - readonly network?: ServerRequest__AdditionalNetworkPermissions | null; +export type ServerRequest__AdditionalFileSystemPermissions = { + readonly entries?: ReadonlyArray | null; + readonly globScanMaxDepth?: number | null; + readonly read?: ReadonlyArray | null; + readonly write?: ReadonlyArray | null; }; -export const ServerRequest__RequestPermissionProfile = Schema.Struct({ - fileSystem: Schema.optionalKey( - Schema.Union([ServerRequest__AdditionalFileSystemPermissions, Schema.Null]), +export const ServerRequest__AdditionalFileSystemPermissions = Schema.Struct({ + entries: Schema.optionalKey( + Schema.Union([Schema.Array(ServerRequest__FileSystemSandboxEntry), Schema.Null]), ), - network: Schema.optionalKey( - Schema.Union([ServerRequest__AdditionalNetworkPermissions, Schema.Null]), + globScanMaxDepth: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "uint" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)), + Schema.Null, + ]), + ), + read: Schema.optionalKey( + Schema.Union([ + Schema.Array(ServerRequest__LegacyAppPathString).annotate({ + description: "This will be removed in favor of `entries`.", + }), + Schema.Null, + ]), + ), + write: Schema.optionalKey( + Schema.Union([ + Schema.Array(ServerRequest__LegacyAppPathString).annotate({ + description: "This will be removed in favor of `entries`.", + }), + Schema.Null, + ]), ), }); @@ -27446,41 +30031,81 @@ export const ServerRequest__McpElicitationEnumSchema = Schema.Union([ ServerRequest__McpElicitationLegacyTitledEnumSchema, ]); -export type V2ItemGuardianApprovalReviewCompletedNotification__RequestPermissionProfile = { - readonly fileSystem?: V2ItemGuardianApprovalReviewCompletedNotification__AdditionalFileSystemPermissions | null; - readonly network?: V2ItemGuardianApprovalReviewCompletedNotification__AdditionalNetworkPermissions | null; +export type V2ItemGuardianApprovalReviewCompletedNotification__AdditionalFileSystemPermissions = { + readonly entries?: ReadonlyArray | null; + readonly globScanMaxDepth?: number | null; + readonly read?: ReadonlyArray | null; + readonly write?: ReadonlyArray | null; }; -export const V2ItemGuardianApprovalReviewCompletedNotification__RequestPermissionProfile = +export const V2ItemGuardianApprovalReviewCompletedNotification__AdditionalFileSystemPermissions = Schema.Struct({ - fileSystem: Schema.optionalKey( + entries: Schema.optionalKey( Schema.Union([ - V2ItemGuardianApprovalReviewCompletedNotification__AdditionalFileSystemPermissions, + Schema.Array(V2ItemGuardianApprovalReviewCompletedNotification__FileSystemSandboxEntry), Schema.Null, ]), ), - network: Schema.optionalKey( + globScanMaxDepth: Schema.optionalKey( Schema.Union([ - V2ItemGuardianApprovalReviewCompletedNotification__AdditionalNetworkPermissions, + Schema.Number.annotate({ format: "uint" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)), + Schema.Null, + ]), + ), + read: Schema.optionalKey( + Schema.Union([ + Schema.Array( + V2ItemGuardianApprovalReviewCompletedNotification__LegacyAppPathString, + ).annotate({ description: "This will be removed in favor of `entries`." }), + Schema.Null, + ]), + ), + write: Schema.optionalKey( + Schema.Union([ + Schema.Array( + V2ItemGuardianApprovalReviewCompletedNotification__LegacyAppPathString, + ).annotate({ description: "This will be removed in favor of `entries`." }), Schema.Null, ]), ), }); -export type V2ItemGuardianApprovalReviewStartedNotification__RequestPermissionProfile = { - readonly fileSystem?: V2ItemGuardianApprovalReviewStartedNotification__AdditionalFileSystemPermissions | null; - readonly network?: V2ItemGuardianApprovalReviewStartedNotification__AdditionalNetworkPermissions | null; +export type V2ItemGuardianApprovalReviewStartedNotification__AdditionalFileSystemPermissions = { + readonly entries?: ReadonlyArray | null; + readonly globScanMaxDepth?: number | null; + readonly read?: ReadonlyArray | null; + readonly write?: ReadonlyArray | null; }; -export const V2ItemGuardianApprovalReviewStartedNotification__RequestPermissionProfile = +export const V2ItemGuardianApprovalReviewStartedNotification__AdditionalFileSystemPermissions = Schema.Struct({ - fileSystem: Schema.optionalKey( + entries: Schema.optionalKey( Schema.Union([ - V2ItemGuardianApprovalReviewStartedNotification__AdditionalFileSystemPermissions, + Schema.Array(V2ItemGuardianApprovalReviewStartedNotification__FileSystemSandboxEntry), Schema.Null, ]), ), - network: Schema.optionalKey( + globScanMaxDepth: Schema.optionalKey( Schema.Union([ - V2ItemGuardianApprovalReviewStartedNotification__AdditionalNetworkPermissions, + Schema.Number.annotate({ format: "uint" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)), + Schema.Null, + ]), + ), + read: Schema.optionalKey( + Schema.Union([ + Schema.Array(V2ItemGuardianApprovalReviewStartedNotification__LegacyAppPathString).annotate( + { description: "This will be removed in favor of `entries`." }, + ), + Schema.Null, + ]), + ), + write: Schema.optionalKey( + Schema.Union([ + Schema.Array(V2ItemGuardianApprovalReviewStartedNotification__LegacyAppPathString).annotate( + { description: "This will be removed in favor of `entries`." }, + ), Schema.Null, ]), ), @@ -27534,6 +30159,8 @@ export type V2PluginReadResponse__PluginDetail = { readonly marketplaceName: string; readonly marketplacePath?: V2PluginReadResponse__AbsolutePathBuf | null; readonly mcpServers: ReadonlyArray; + readonly scheduledTasks?: ReadonlyArray | null; + readonly shareUrl?: string | null; readonly skills: ReadonlyArray; readonly summary: V2PluginReadResponse__PluginSummary; }; @@ -27547,6 +30174,10 @@ export const V2PluginReadResponse__PluginDetail = Schema.Struct({ Schema.Union([V2PluginReadResponse__AbsolutePathBuf, Schema.Null]), ), mcpServers: Schema.Array(Schema.String), + scheduledTasks: Schema.optionalKey( + Schema.Union([Schema.Array(V2PluginReadResponse__ScheduledTaskSummary), Schema.Null]), + ), + shareUrl: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), skills: Schema.Array(V2PluginReadResponse__SkillSummary), summary: V2PluginReadResponse__PluginSummary, }); @@ -27577,6 +30208,7 @@ export type V2ThreadForkResponse__Thread = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -27643,7 +30275,9 @@ export const V2ThreadForkResponse__Thread = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -27671,6 +30305,15 @@ export const V2ThreadForkResponse__Thread = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -27734,6 +30377,7 @@ export type V2ThreadListResponse__Thread = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -27800,7 +30444,9 @@ export const V2ThreadListResponse__Thread = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -27828,6 +30474,15 @@ export const V2ThreadListResponse__Thread = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -27891,6 +30546,7 @@ export type V2ThreadMetadataUpdateResponse__Thread = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -27957,7 +30613,9 @@ export const V2ThreadMetadataUpdateResponse__Thread = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -27985,6 +30643,15 @@ export const V2ThreadMetadataUpdateResponse__Thread = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -28048,6 +30715,7 @@ export type V2ThreadReadResponse__Thread = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -28114,7 +30782,9 @@ export const V2ThreadReadResponse__Thread = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -28142,6 +30812,15 @@ export const V2ThreadReadResponse__Thread = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -28205,6 +30884,7 @@ export type V2ThreadResumeResponse__Thread = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -28271,7 +30951,9 @@ export const V2ThreadResumeResponse__Thread = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -28299,6 +30981,15 @@ export const V2ThreadResumeResponse__Thread = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -28362,6 +31053,7 @@ export type V2ThreadStartedNotification__Thread = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -28428,7 +31120,9 @@ export const V2ThreadStartedNotification__Thread = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -28456,6 +31150,15 @@ export const V2ThreadStartedNotification__Thread = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -28519,6 +31222,7 @@ export type V2ThreadStartResponse__Thread = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -28585,7 +31289,9 @@ export const V2ThreadStartResponse__Thread = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -28613,6 +31319,15 @@ export const V2ThreadStartResponse__Thread = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -28676,6 +31391,7 @@ export type V2ThreadUnarchiveResponse__Thread = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -28742,7 +31458,9 @@ export const V2ThreadUnarchiveResponse__Thread = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -28770,6 +31488,15 @@ export const V2ThreadUnarchiveResponse__Thread = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -28830,6 +31557,141 @@ export const McpServerElicitationRequestParams__McpElicitationPrimitiveSchema = McpServerElicitationRequestParams__McpElicitationBooleanSchema, ]); +export type PermissionsRequestApprovalParams__RequestPermissionProfile = { + readonly fileSystem?: PermissionsRequestApprovalParams__AdditionalFileSystemPermissions | null; + readonly network?: PermissionsRequestApprovalParams__AdditionalNetworkPermissions | null; +}; +export const PermissionsRequestApprovalParams__RequestPermissionProfile = Schema.Struct({ + fileSystem: Schema.optionalKey( + Schema.Union([PermissionsRequestApprovalParams__AdditionalFileSystemPermissions, Schema.Null]), + ), + network: Schema.optionalKey( + Schema.Union([PermissionsRequestApprovalParams__AdditionalNetworkPermissions, Schema.Null]), + ), +}); + +export type PermissionsRequestApprovalResponse__GrantedPermissionProfile = { + readonly fileSystem?: PermissionsRequestApprovalResponse__AdditionalFileSystemPermissions | null; + readonly network?: PermissionsRequestApprovalResponse__AdditionalNetworkPermissions | null; +}; +export const PermissionsRequestApprovalResponse__GrantedPermissionProfile = Schema.Struct({ + fileSystem: Schema.optionalKey( + Schema.Union([ + PermissionsRequestApprovalResponse__AdditionalFileSystemPermissions, + Schema.Null, + ]), + ), + network: Schema.optionalKey( + Schema.Union([PermissionsRequestApprovalResponse__AdditionalNetworkPermissions, Schema.Null]), + ), +}); + +export type ServerNotification__RequestPermissionProfile = { + readonly fileSystem?: ServerNotification__AdditionalFileSystemPermissions | null; + readonly network?: ServerNotification__AdditionalNetworkPermissions | null; +}; +export const ServerNotification__RequestPermissionProfile = Schema.Struct({ + fileSystem: Schema.optionalKey( + Schema.Union([ServerNotification__AdditionalFileSystemPermissions, Schema.Null]), + ), + network: Schema.optionalKey( + Schema.Union([ServerNotification__AdditionalNetworkPermissions, Schema.Null]), + ), +}); + +export type ServerNotification__ThreadStartedNotification = { + readonly thread: ServerNotification__Thread; +}; +export const ServerNotification__ThreadStartedNotification = Schema.Struct({ + thread: ServerNotification__Thread, +}); + +export type ServerRequest__RequestPermissionProfile = { + readonly fileSystem?: ServerRequest__AdditionalFileSystemPermissions | null; + readonly network?: ServerRequest__AdditionalNetworkPermissions | null; +}; +export const ServerRequest__RequestPermissionProfile = Schema.Struct({ + fileSystem: Schema.optionalKey( + Schema.Union([ServerRequest__AdditionalFileSystemPermissions, Schema.Null]), + ), + network: Schema.optionalKey( + Schema.Union([ServerRequest__AdditionalNetworkPermissions, Schema.Null]), + ), +}); + +export type ServerRequest__McpElicitationPrimitiveSchema = + | ServerRequest__McpElicitationEnumSchema + | ServerRequest__McpElicitationStringSchema + | ServerRequest__McpElicitationNumberSchema + | ServerRequest__McpElicitationBooleanSchema; +export const ServerRequest__McpElicitationPrimitiveSchema = Schema.Union([ + ServerRequest__McpElicitationEnumSchema, + ServerRequest__McpElicitationStringSchema, + ServerRequest__McpElicitationNumberSchema, + ServerRequest__McpElicitationBooleanSchema, +]); + +export type V2ItemGuardianApprovalReviewCompletedNotification__RequestPermissionProfile = { + readonly fileSystem?: V2ItemGuardianApprovalReviewCompletedNotification__AdditionalFileSystemPermissions | null; + readonly network?: V2ItemGuardianApprovalReviewCompletedNotification__AdditionalNetworkPermissions | null; +}; +export const V2ItemGuardianApprovalReviewCompletedNotification__RequestPermissionProfile = + Schema.Struct({ + fileSystem: Schema.optionalKey( + Schema.Union([ + V2ItemGuardianApprovalReviewCompletedNotification__AdditionalFileSystemPermissions, + Schema.Null, + ]), + ), + network: Schema.optionalKey( + Schema.Union([ + V2ItemGuardianApprovalReviewCompletedNotification__AdditionalNetworkPermissions, + Schema.Null, + ]), + ), + }); + +export type V2ItemGuardianApprovalReviewStartedNotification__RequestPermissionProfile = { + readonly fileSystem?: V2ItemGuardianApprovalReviewStartedNotification__AdditionalFileSystemPermissions | null; + readonly network?: V2ItemGuardianApprovalReviewStartedNotification__AdditionalNetworkPermissions | null; +}; +export const V2ItemGuardianApprovalReviewStartedNotification__RequestPermissionProfile = + Schema.Struct({ + fileSystem: Schema.optionalKey( + Schema.Union([ + V2ItemGuardianApprovalReviewStartedNotification__AdditionalFileSystemPermissions, + Schema.Null, + ]), + ), + network: Schema.optionalKey( + Schema.Union([ + V2ItemGuardianApprovalReviewStartedNotification__AdditionalNetworkPermissions, + Schema.Null, + ]), + ), + }); + +export type McpServerElicitationRequestParams__McpElicitationSchema = { + readonly $schema?: string | null; + readonly properties: { + readonly [x: string]: McpServerElicitationRequestParams__McpElicitationPrimitiveSchema; + }; + readonly required?: ReadonlyArray | null; + readonly type: McpServerElicitationRequestParams__McpElicitationObjectType; +}; +export const McpServerElicitationRequestParams__McpElicitationSchema = Schema.Struct({ + $schema: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + properties: Schema.Record( + Schema.String, + McpServerElicitationRequestParams__McpElicitationPrimitiveSchema, + ), + required: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])), + type: McpServerElicitationRequestParams__McpElicitationObjectType, +}).annotate({ + description: + "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", +}); + export type ServerNotification__GuardianApprovalReviewAction = | { readonly command: string; @@ -28925,13 +31787,6 @@ export const ServerNotification__GuardianApprovalReviewAction = Schema.Union( { mode: "oneOf" }, ); -export type ServerNotification__ThreadStartedNotification = { - readonly thread: ServerNotification__Thread; -}; -export const ServerNotification__ThreadStartedNotification = Schema.Struct({ - thread: ServerNotification__Thread, -}); - export type ServerRequest__PermissionsRequestApprovalParams = { readonly cwd: ServerRequest__AbsolutePathBuf; readonly environmentId?: string | null; @@ -28956,17 +31811,21 @@ export const ServerRequest__PermissionsRequestApprovalParams = Schema.Struct({ turnId: Schema.String, }); -export type ServerRequest__McpElicitationPrimitiveSchema = - | ServerRequest__McpElicitationEnumSchema - | ServerRequest__McpElicitationStringSchema - | ServerRequest__McpElicitationNumberSchema - | ServerRequest__McpElicitationBooleanSchema; -export const ServerRequest__McpElicitationPrimitiveSchema = Schema.Union([ - ServerRequest__McpElicitationEnumSchema, - ServerRequest__McpElicitationStringSchema, - ServerRequest__McpElicitationNumberSchema, - ServerRequest__McpElicitationBooleanSchema, -]); +export type ServerRequest__McpElicitationSchema = { + readonly $schema?: string | null; + readonly properties: { readonly [x: string]: ServerRequest__McpElicitationPrimitiveSchema }; + readonly required?: ReadonlyArray | null; + readonly type: ServerRequest__McpElicitationObjectType; +}; +export const ServerRequest__McpElicitationSchema = Schema.Struct({ + $schema: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + properties: Schema.Record(Schema.String, ServerRequest__McpElicitationPrimitiveSchema), + required: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])), + type: ServerRequest__McpElicitationObjectType, +}).annotate({ + description: + "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", +}); export type V2ItemGuardianApprovalReviewCompletedNotification__GuardianApprovalReviewAction = | { @@ -29164,27 +32023,6 @@ export const V2ItemGuardianApprovalReviewStartedNotification__GuardianApprovalRe { mode: "oneOf" }, ); -export type McpServerElicitationRequestParams__McpElicitationSchema = { - readonly $schema?: string | null; - readonly properties: { - readonly [x: string]: McpServerElicitationRequestParams__McpElicitationPrimitiveSchema; - }; - readonly required?: ReadonlyArray | null; - readonly type: McpServerElicitationRequestParams__McpElicitationObjectType; -}; -export const McpServerElicitationRequestParams__McpElicitationSchema = Schema.Struct({ - $schema: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - properties: Schema.Record( - Schema.String, - McpServerElicitationRequestParams__McpElicitationPrimitiveSchema, - ), - required: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])), - type: McpServerElicitationRequestParams__McpElicitationObjectType, -}).annotate({ - description: - "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", -}); - export type ServerNotification__ItemGuardianApprovalReviewCompletedNotification = { readonly action: ServerNotification__GuardianApprovalReviewAction; readonly completedAtMs: number; @@ -29258,22 +32096,6 @@ export const ServerNotification__ItemGuardianApprovalReviewStartedNotification = "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", }); -export type ServerRequest__McpElicitationSchema = { - readonly $schema?: string | null; - readonly properties: { readonly [x: string]: ServerRequest__McpElicitationPrimitiveSchema }; - readonly required?: ReadonlyArray | null; - readonly type: ServerRequest__McpElicitationObjectType; -}; -export const ServerRequest__McpElicitationSchema = Schema.Struct({ - $schema: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - properties: Schema.Record(Schema.String, ServerRequest__McpElicitationPrimitiveSchema), - required: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])), - type: ServerRequest__McpElicitationObjectType, -}).annotate({ - description: - "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", -}); - export type ServerRequest__McpServerElicitationRequestParams = | { readonly _meta?: unknown; @@ -29284,6 +32106,15 @@ export type ServerRequest__McpServerElicitationRequestParams = readonly threadId: string; readonly turnId?: string | null; } + | { + readonly _meta?: unknown; + readonly message: string; + readonly mode: "openai/form"; + readonly requestedSchema: unknown; + readonly serverName: string; + readonly threadId: string; + readonly turnId?: string | null; + } | { readonly _meta?: unknown; readonly elicitationId: string; @@ -29313,6 +32144,23 @@ export const ServerRequest__McpServerElicitationRequestParams = Schema.Union( ]), ), }), + Schema.Struct({ + _meta: Schema.optionalKey(Schema.Unknown), + message: Schema.String, + mode: Schema.Literal("openai/form"), + requestedSchema: Schema.Unknown, + serverName: Schema.String, + threadId: Schema.String, + turnId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.", + }), + Schema.Null, + ]), + ), + }), Schema.Struct({ _meta: Schema.optionalKey(Schema.Unknown), elicitationId: Schema.String, @@ -29604,11 +32452,21 @@ export type ClientRequest = readonly method: "plugin/share/delete"; readonly params: ClientRequest__PluginShareDeleteParams; } + | { + readonly id: ClientRequest__RequestId; + readonly method: "app/read"; + readonly params: ClientRequest__AppsReadParams; + } | { readonly id: ClientRequest__RequestId; readonly method: "app/list"; readonly params: ClientRequest__AppsListParams; } + | { + readonly id: ClientRequest__RequestId; + readonly method: "app/installed"; + readonly params: ClientRequest__AppsInstalledParams; + } | { readonly id: ClientRequest__RequestId; readonly method: "fs/readFile"; @@ -29769,11 +32627,21 @@ export type ClientRequest = readonly method: "account/rateLimits/read"; readonly params?: null; } + | { + readonly id: ClientRequest__RequestId; + readonly method: "account/rateLimitResetCredit/consume"; + readonly params: ClientRequest__ConsumeAccountRateLimitResetCreditParams; + } | { readonly id: ClientRequest__RequestId; readonly method: "account/usage/read"; readonly params?: null; } + | { + readonly id: ClientRequest__RequestId; + readonly method: "account/workspaceMessages/read"; + readonly params?: null; + } | { readonly id: ClientRequest__RequestId; readonly method: "account/sendAddCreditsNudgeEmail"; @@ -29819,6 +32687,11 @@ export type ClientRequest = readonly method: "externalAgentConfig/import"; readonly params: ClientRequest__ExternalAgentConfigImportParams; } + | { + readonly id: ClientRequest__RequestId; + readonly method: "externalAgentConfig/import/readHistories"; + readonly params?: null; + } | { readonly id: ClientRequest__RequestId; readonly method: "config/value/write"; @@ -30068,11 +32941,21 @@ export const ClientRequest = Schema.Union( }), params: ClientRequest__PluginShareDeleteParams, }).annotate({ title: "Plugin/share/deleteRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("app/read").annotate({ title: "App/readRequestMethod" }), + params: ClientRequest__AppsReadParams, + }).annotate({ title: "App/readRequest" }), Schema.Struct({ id: ClientRequest__RequestId, method: Schema.Literal("app/list").annotate({ title: "App/listRequestMethod" }), params: ClientRequest__AppsListParams, }).annotate({ title: "App/listRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("app/installed").annotate({ title: "App/installedRequestMethod" }), + params: ClientRequest__AppsInstalledParams, + }).annotate({ title: "App/installedRequest" }), Schema.Struct({ id: ClientRequest__RequestId, method: Schema.Literal("fs/readFile").annotate({ title: "Fs/readFileRequestMethod" }), @@ -30269,6 +33152,13 @@ export const ClientRequest = Schema.Union( }), params: Schema.optionalKey(Schema.Null), }).annotate({ title: "Account/rateLimits/readRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("account/rateLimitResetCredit/consume").annotate({ + title: "Account/rateLimitResetCredit/consumeRequestMethod", + }), + params: ClientRequest__ConsumeAccountRateLimitResetCreditParams, + }).annotate({ title: "Account/rateLimitResetCredit/consumeRequest" }), Schema.Struct({ id: ClientRequest__RequestId, method: Schema.Literal("account/usage/read").annotate({ @@ -30276,6 +33166,13 @@ export const ClientRequest = Schema.Union( }), params: Schema.optionalKey(Schema.Null), }).annotate({ title: "Account/usage/readRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("account/workspaceMessages/read").annotate({ + title: "Account/workspaceMessages/readRequestMethod", + }), + params: Schema.optionalKey(Schema.Null), + }).annotate({ title: "Account/workspaceMessages/readRequest" }), Schema.Struct({ id: ClientRequest__RequestId, method: Schema.Literal("account/sendAddCreditsNudgeEmail").annotate({ @@ -30346,6 +33243,13 @@ export const ClientRequest = Schema.Union( }), params: ClientRequest__ExternalAgentConfigImportParams, }).annotate({ title: "ExternalAgentConfig/importRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("externalAgentConfig/import/readHistories").annotate({ + title: "ExternalAgentConfig/import/readHistoriesRequestMethod", + }), + params: Schema.optionalKey(Schema.Null), + }).annotate({ title: "ExternalAgentConfig/import/readHistoriesRequest" }), Schema.Struct({ id: ClientRequest__RequestId, method: Schema.Literal("config/value/write").annotate({ @@ -30409,7 +33313,9 @@ export const ClientRequest__CapabilityRootLocation = Schema.Union( [ Schema.Struct({ environmentId: Schema.String, - path: Schema.String, + path: Schema.String.annotate({ + description: "Absolute path for the root in the selected environment.", + }), type: Schema.Literal("environment").annotate({ title: "EnvironmentCapabilityRootLocationType", }), @@ -30421,6 +33327,13 @@ export const ClientRequest__CapabilityRootLocation = Schema.Union( { mode: "oneOf" }, ).annotate({ description: "Location used to resolve a selected capability root." }); +export type ClientRequest__CodexResponseHandoffMode = "thinking" | "commentary" | "bemTags"; +export const ClientRequest__CodexResponseHandoffMode = Schema.Literals([ + "thinking", + "commentary", + "bemTags", +]); + export type ClientRequest__CollaborationMode = { readonly mode: ClientRequest__ModeKind; readonly settings: ClientRequest__Settings; @@ -30430,19 +33343,52 @@ export const ClientRequest__CollaborationMode = Schema.Struct({ settings: ClientRequest__Settings, }).annotate({ description: "Collaboration mode for a Codex session." }); -export type ClientRequest__DynamicToolSpec = { - readonly deferLoading?: boolean; - readonly description: string; - readonly inputSchema: unknown; - readonly name: string; - readonly namespace?: string | null; -}; -export const ClientRequest__DynamicToolSpec = Schema.Struct({ - deferLoading: Schema.optionalKey(Schema.Boolean), - description: Schema.String, - inputSchema: Schema.Unknown, - name: Schema.String, - namespace: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +export type ClientRequest__DynamicToolSpec = + | { + readonly deferLoading?: boolean; + readonly description: string; + readonly inputSchema: unknown; + readonly name: string; + readonly type: "function"; + } + | { + readonly description: string; + readonly name: string; + readonly tools: ReadonlyArray; + readonly type: "namespace"; + }; +export const ClientRequest__DynamicToolSpec = Schema.Union( + [ + Schema.Struct({ + deferLoading: Schema.optionalKey(Schema.Boolean), + description: Schema.String, + inputSchema: Schema.Unknown, + name: Schema.String, + type: Schema.Literal("function").annotate({ title: "FunctionDynamicToolSpecType" }), + }).annotate({ title: "FunctionDynamicToolSpec" }), + Schema.Struct({ + description: Schema.String, + name: Schema.String, + tools: Schema.Array(ClientRequest__DynamicToolNamespaceTool), + type: Schema.Literal("namespace").annotate({ title: "NamespaceDynamicToolSpecType" }), + }).annotate({ title: "NamespaceDynamicToolSpec" }), + ], + { mode: "oneOf" }, +); + +export type ClientRequest__MultiAgentMode = + | "explicitRequestOnly" + | "proactive" + | { readonly custom: string }; +export const ClientRequest__MultiAgentMode = Schema.Union( + [ + Schema.Literals(["explicitRequestOnly", "proactive"]), + Schema.Struct({ custom: Schema.String }).annotate({ title: "CustomMultiAgentMode" }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", }); export type ClientRequest__NetworkAccess = "restricted" | "enabled"; @@ -30464,8 +33410,8 @@ export const ClientRequest__ProcessTerminalSize = Schema.Struct({ .check(Schema.isGreaterThanOrEqualTo(0)), }).annotate({ description: "PTY size in character cells for `process/spawn` PTY sessions." }); -export type ClientRequest__RealtimeConversationVersion = "v1" | "v2"; -export const ClientRequest__RealtimeConversationVersion = Schema.Literals(["v1", "v2"]); +export type ClientRequest__RealtimeConversationVersion = "v1" | "v2" | "v3"; +export const ClientRequest__RealtimeConversationVersion = Schema.Literals(["v1", "v2", "v3"]); export type ClientRequest__RealtimeOutputModality = "text" | "audio"; export const ClientRequest__RealtimeOutputModality = Schema.Literals(["text", "audio"]); @@ -30512,10 +33458,21 @@ export const ClientRequest__RealtimeVoice = Schema.Literals([ "verse", ]); +export type ClientRequest__RemoteControlDisableParams = { readonly ephemeral?: boolean }; +export const ClientRequest__RemoteControlDisableParams = Schema.Struct({ + ephemeral: Schema.optionalKey(Schema.Boolean), +}); + +export type ClientRequest__RemoteControlEnableParams = { readonly ephemeral?: boolean }; +export const ClientRequest__RemoteControlEnableParams = Schema.Struct({ + ephemeral: Schema.optionalKey(Schema.Boolean), +}); + export type ClientRequest__ResponseItem = | { readonly content: ReadonlyArray; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly phase?: ClientRequest__MessagePhase | null; readonly role: string; readonly type: "message"; @@ -30523,12 +33480,16 @@ export type ClientRequest__ResponseItem = | { readonly author: string; readonly content: ReadonlyArray; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly recipient: string; readonly type: "agent_message"; } | { readonly content?: ReadonlyArray | null; readonly encrypted_content?: string | null; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly summary: ReadonlyArray; readonly type: "reasoning"; } @@ -30536,6 +33497,7 @@ export type ClientRequest__ResponseItem = readonly action: ClientRequest__LocalShellAction; readonly call_id?: string | null; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly status: ClientRequest__LocalShellStatus; readonly type: "local_shell_call"; } @@ -30543,6 +33505,7 @@ export type ClientRequest__ResponseItem = readonly arguments: string; readonly call_id: string; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly name: string; readonly namespace?: string | null; readonly type: "function_call"; @@ -30552,11 +33515,14 @@ export type ClientRequest__ResponseItem = readonly call_id?: string | null; readonly execution: string; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly status?: string | null; readonly type: "tool_search_call"; } | { readonly call_id: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly output: ClientRequest__FunctionCallOutputBody; readonly type: "function_call_output"; } @@ -30564,12 +33530,16 @@ export type ClientRequest__ResponseItem = readonly call_id: string; readonly id?: string | null; readonly input: string; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly name: string; + readonly namespace?: string | null; readonly status?: string | null; readonly type: "custom_tool_call"; } | { readonly call_id: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly name?: string | null; readonly output: ClientRequest__FunctionCallOutputBody; readonly type: "custom_tool_call_output"; @@ -30577,6 +33547,8 @@ export type ClientRequest__ResponseItem = | { readonly call_id?: string | null; readonly execution: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly status: string; readonly tools: ReadonlyArray; readonly type: "tool_search_output"; @@ -30584,26 +33556,39 @@ export type ClientRequest__ResponseItem = | { readonly action?: ClientRequest__ResponsesApiWebSearchAction | null; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly status?: string | null; readonly type: "web_search_call"; } | { - readonly id: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; readonly result: string; readonly revised_prompt?: string | null; readonly status: string; readonly type: "image_generation_call"; } - | { readonly encrypted_content: string; readonly type: "compaction" } + | { + readonly encrypted_content: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; + readonly type: "compaction"; + } | { readonly type: "compaction_trigger" } - | { readonly encrypted_content?: string | null; readonly type: "context_compaction" } + | { + readonly encrypted_content?: string | null; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: ClientRequest__InternalChatMessageMetadataPassthrough | null; + readonly type: "context_compaction"; + } | { readonly type: "other" }; export const ClientRequest__ResponseItem = Schema.Union( [ Schema.Struct({ content: Schema.Array(ClientRequest__ContentItem), - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), ), phase: Schema.optionalKey(Schema.Union([ClientRequest__MessagePhase, Schema.Null])), role: Schema.String, @@ -30612,6 +33597,10 @@ export const ClientRequest__ResponseItem = Schema.Union( Schema.Struct({ author: Schema.String, content: Schema.Array(ClientRequest__AgentMessageInputContent), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), recipient: Schema.String, type: Schema.Literal("agent_message").annotate({ title: "AgentMessageResponseItemType" }), }).annotate({ title: "AgentMessageResponseItem" }), @@ -30620,6 +33609,10 @@ export const ClientRequest__ResponseItem = Schema.Union( Schema.Union([Schema.Array(ClientRequest__ReasoningItemContent), Schema.Null]), ), encrypted_content: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), summary: Schema.Array(ClientRequest__ReasoningItemReasoningSummary), type: Schema.Literal("reasoning").annotate({ title: "ReasoningResponseItemType" }), }).annotate({ title: "ReasoningResponseItem" }), @@ -30635,11 +33628,13 @@ export const ClientRequest__ResponseItem = Schema.Union( Schema.Union([ Schema.String.annotate({ description: "Legacy id field retained for compatibility with older payloads.", - writeOnly: true, }), Schema.Null, ]), ), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), status: ClientRequest__LocalShellStatus, type: Schema.Literal("local_shell_call").annotate({ title: "LocalShellCallResponseItemType", @@ -30648,8 +33643,9 @@ export const ClientRequest__ResponseItem = Schema.Union( Schema.Struct({ arguments: Schema.String, call_id: Schema.String, - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), ), name: Schema.String, namespace: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), @@ -30659,8 +33655,9 @@ export const ClientRequest__ResponseItem = Schema.Union( arguments: Schema.Unknown, call_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), execution: Schema.String, - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), ), status: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.Literal("tool_search_call").annotate({ @@ -30669,6 +33666,10 @@ export const ClientRequest__ResponseItem = Schema.Union( }).annotate({ title: "ToolSearchCallResponseItem" }), Schema.Struct({ call_id: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), output: ClientRequest__FunctionCallOutputBody, type: Schema.Literal("function_call_output").annotate({ title: "FunctionCallOutputResponseItemType", @@ -30676,11 +33677,13 @@ export const ClientRequest__ResponseItem = Schema.Union( }).annotate({ title: "FunctionCallOutputResponseItem" }), Schema.Struct({ call_id: Schema.String, - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), - ), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), input: Schema.String, + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), name: Schema.String, + namespace: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), status: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.Literal("custom_tool_call").annotate({ title: "CustomToolCallResponseItemType", @@ -30688,6 +33691,10 @@ export const ClientRequest__ResponseItem = Schema.Union( }).annotate({ title: "CustomToolCallResponseItem" }), Schema.Struct({ call_id: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), name: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), output: ClientRequest__FunctionCallOutputBody, type: Schema.Literal("custom_tool_call_output").annotate({ @@ -30697,6 +33704,10 @@ export const ClientRequest__ResponseItem = Schema.Union( Schema.Struct({ call_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), execution: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), status: Schema.String, tools: Schema.Array(Schema.Unknown), type: Schema.Literal("tool_search_output").annotate({ @@ -30707,14 +33718,18 @@ export const ClientRequest__ResponseItem = Schema.Union( action: Schema.optionalKey( Schema.Union([ClientRequest__ResponsesApiWebSearchAction, Schema.Null]), ), - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), ), status: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.Literal("web_search_call").annotate({ title: "WebSearchCallResponseItemType" }), }).annotate({ title: "WebSearchCallResponseItem" }), Schema.Struct({ - id: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), result: Schema.String, revised_prompt: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), status: Schema.String, @@ -30724,6 +33739,10 @@ export const ClientRequest__ResponseItem = Schema.Union( }).annotate({ title: "ImageGenerationCallResponseItem" }), Schema.Struct({ encrypted_content: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), type: Schema.Literal("compaction").annotate({ title: "CompactionResponseItemType" }), }).annotate({ title: "CompactionResponseItem" }), Schema.Struct({ @@ -30733,6 +33752,10 @@ export const ClientRequest__ResponseItem = Schema.Union( }).annotate({ title: "CompactionTriggerResponseItem" }), Schema.Struct({ encrypted_content: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([ClientRequest__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), type: Schema.Literal("context_compaction").annotate({ title: "ContextCompactionResponseItemType", }), @@ -30760,7 +33783,9 @@ export const ClientRequest__SelectedCapabilityRoot = Schema.Struct({ [ Schema.Struct({ environmentId: Schema.String, - path: Schema.String, + path: Schema.String.annotate({ + description: "Absolute path for the root in the selected environment.", + }), type: Schema.Literal("environment").annotate({ title: "EnvironmentCapabilityRootLocationType", }), @@ -30775,6 +33800,9 @@ export const ClientRequest__SelectedCapabilityRoot = Schema.Struct({ description: "A user-selected root that can expose one or more runtime capabilities.", }); +export type ClientRequest__ThreadHistoryMode = "legacy" | "paginated"; +export const ClientRequest__ThreadHistoryMode = Schema.Literals(["legacy", "paginated"]); + export type ClientRequest__ThreadMemoryMode = "enabled" | "disabled"; export const ClientRequest__ThreadMemoryMode = Schema.Literals(["enabled", "disabled"]); @@ -30804,6 +33832,17 @@ export const ClientRequest__ThreadRealtimeAudioChunk = Schema.Struct({ ), }).annotate({ description: "EXPERIMENTAL - thread realtime audio chunk." }); +export type ClientRequest__ThreadRealtimeInitialItem = { + readonly role: ClientRequest__ConversationTextRole; + readonly text: string; +}; +export const ClientRequest__ThreadRealtimeInitialItem = Schema.Struct({ + role: ClientRequest__ConversationTextRole, + text: Schema.String, +}).annotate({ + description: "EXPERIMENTAL - role-bearing text item included when a realtime V3 session starts.", +}); + export type ClientRequest__ThreadRealtimeStartTransport = | { readonly type: "websocket" } | { readonly sdp: string; readonly type: "webrtc" }; @@ -30852,19 +33891,29 @@ export const ClientRequest__ThreadResumeInitialTurnsPageParams = Schema.Struct({ }); export type ClientRequest__TurnEnvironmentParams = { - readonly cwd: ClientRequest__AbsolutePathBuf; + readonly cwd: ClientRequest__LegacyAppPathString; readonly environmentId: string; + readonly runtimeWorkspaceRoots?: ReadonlyArray | null; }; export const ClientRequest__TurnEnvironmentParams = Schema.Struct({ - cwd: ClientRequest__AbsolutePathBuf, + cwd: ClientRequest__LegacyAppPathString, environmentId: Schema.String, + runtimeWorkspaceRoots: Schema.optionalKey( + Schema.Union([ + Schema.Array(ClientRequest__LegacyAppPathString).annotate({ + description: "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", + }), + Schema.Null, + ]), + ), }); export type CommandExecutionRequestApprovalParams = { readonly approvalId?: string | null; readonly command?: string | null; readonly commandActions?: ReadonlyArray | null; - readonly cwd?: CommandExecutionRequestApprovalParams__AbsolutePathBuf | null; + readonly cwd?: CommandExecutionRequestApprovalParams__LegacyAppPathString | null; + readonly environmentId?: string | null; readonly itemId: string; readonly networkApprovalContext?: CommandExecutionRequestApprovalParams__NetworkApprovalContext | null; readonly proposedExecpolicyAmendment?: ReadonlyArray | null; @@ -30899,9 +33948,16 @@ export const CommandExecutionRequestApprovalParams = Schema.Struct({ ]), ), cwd: Schema.optionalKey( - Schema.Union([CommandExecutionRequestApprovalParams__AbsolutePathBuf, Schema.Null]).annotate({ - description: "The command's working directory.", - }), + Schema.Union([ + CommandExecutionRequestApprovalParams__LegacyAppPathString, + Schema.Null, + ]).annotate({ description: "The command's working directory." }), + ), + environmentId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Environment in which the command will run." }), + Schema.Null, + ]), ), itemId: Schema.String, networkApprovalContext: Schema.optionalKey( @@ -31283,6 +34339,15 @@ export type McpServerElicitationRequestParams = readonly threadId: string; readonly turnId?: string | null; } + | { + readonly _meta?: unknown; + readonly message: string; + readonly mode: "openai/form"; + readonly requestedSchema: unknown; + readonly serverName: string; + readonly threadId: string; + readonly turnId?: string | null; + } | { readonly _meta?: unknown; readonly elicitationId: string; @@ -31312,6 +34377,23 @@ export const McpServerElicitationRequestParams = Schema.Union( ]), ), }).annotate({ title: "McpServerElicitationRequestParams" }), + Schema.Struct({ + _meta: Schema.optionalKey(Schema.Unknown), + message: Schema.String, + mode: Schema.Literal("openai/form"), + requestedSchema: Schema.Unknown, + serverName: Schema.String, + threadId: Schema.String, + turnId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.", + }), + Schema.Null, + ]), + ), + }).annotate({ title: "McpServerElicitationRequestParams" }), Schema.Struct({ _meta: Schema.optionalKey(Schema.Unknown), elicitationId: Schema.String, @@ -31410,677 +34492,1469 @@ export const RequestId = Schema.Union([ ]).annotate({ title: "RequestId" }); export type ServerNotification = - | { readonly method: "error"; readonly params: ServerNotification__ErrorNotification } + | { + readonly method: "error"; + readonly params: ServerNotification__ErrorNotification; + readonly emittedAtMs?: number; + } | { readonly method: "thread/started"; readonly params: ServerNotification__ThreadStartedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/status/changed"; readonly params: ServerNotification__ThreadStatusChangedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/archived"; readonly params: ServerNotification__ThreadArchivedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/deleted"; readonly params: ServerNotification__ThreadDeletedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/unarchived"; readonly params: ServerNotification__ThreadUnarchivedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/closed"; readonly params: ServerNotification__ThreadClosedNotification; + readonly emittedAtMs?: number; } | { readonly method: "skills/changed"; readonly params: ServerNotification__SkillsChangedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/name/updated"; readonly params: ServerNotification__ThreadNameUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/goal/updated"; readonly params: ServerNotification__ThreadGoalUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/goal/cleared"; readonly params: ServerNotification__ThreadGoalClearedNotification; + readonly emittedAtMs?: number; + } + | { + readonly method: "thread/environment/connected"; + readonly params: ServerNotification__EnvironmentConnectionNotification; + readonly emittedAtMs?: number; + } + | { + readonly method: "thread/environment/disconnected"; + readonly params: ServerNotification__EnvironmentConnectionNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/settings/updated"; readonly params: ServerNotification__ThreadSettingsUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/tokenUsage/updated"; readonly params: ServerNotification__ThreadTokenUsageUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "turn/started"; readonly params: ServerNotification__TurnStartedNotification; + readonly emittedAtMs?: number; } | { readonly method: "hook/started"; readonly params: ServerNotification__HookStartedNotification; + readonly emittedAtMs?: number; } | { readonly method: "turn/completed"; readonly params: ServerNotification__TurnCompletedNotification; + readonly emittedAtMs?: number; } | { readonly method: "hook/completed"; readonly params: ServerNotification__HookCompletedNotification; + readonly emittedAtMs?: number; } | { readonly method: "turn/diff/updated"; readonly params: ServerNotification__TurnDiffUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "turn/plan/updated"; readonly params: ServerNotification__TurnPlanUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/started"; readonly params: ServerNotification__ItemStartedNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/autoApprovalReview/started"; readonly params: ServerNotification__ItemGuardianApprovalReviewStartedNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/autoApprovalReview/completed"; readonly params: ServerNotification__ItemGuardianApprovalReviewCompletedNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/completed"; readonly params: ServerNotification__ItemCompletedNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/agentMessage/delta"; readonly params: ServerNotification__AgentMessageDeltaNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/plan/delta"; readonly params: ServerNotification__PlanDeltaNotification; + readonly emittedAtMs?: number; } | { readonly method: "command/exec/outputDelta"; readonly params: ServerNotification__CommandExecOutputDeltaNotification; + readonly emittedAtMs?: number; } | { readonly method: "process/outputDelta"; readonly params: ServerNotification__ProcessOutputDeltaNotification; + readonly emittedAtMs?: number; } | { readonly method: "process/exited"; readonly params: ServerNotification__ProcessExitedNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/commandExecution/outputDelta"; readonly params: ServerNotification__CommandExecutionOutputDeltaNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/commandExecution/terminalInteraction"; readonly params: ServerNotification__TerminalInteractionNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/fileChange/outputDelta"; readonly params: ServerNotification__FileChangeOutputDeltaNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/fileChange/patchUpdated"; readonly params: ServerNotification__FileChangePatchUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "serverRequest/resolved"; readonly params: ServerNotification__ServerRequestResolvedNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/mcpToolCall/progress"; readonly params: ServerNotification__McpToolCallProgressNotification; + readonly emittedAtMs?: number; } | { readonly method: "mcpServer/oauthLogin/completed"; readonly params: ServerNotification__McpServerOauthLoginCompletedNotification; + readonly emittedAtMs?: number; } | { readonly method: "mcpServer/startupStatus/updated"; readonly params: ServerNotification__McpServerStatusUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "account/updated"; readonly params: ServerNotification__AccountUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "account/rateLimits/updated"; readonly params: ServerNotification__AccountRateLimitsUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "app/list/updated"; readonly params: ServerNotification__AppListUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "remoteControl/status/changed"; readonly params: ServerNotification__RemoteControlStatusChangedNotification; + readonly emittedAtMs?: number; + } + | { + readonly method: "externalAgentConfig/import/progress"; + readonly params: ServerNotification__ExternalAgentConfigImportProgressNotification; + readonly emittedAtMs?: number; } | { readonly method: "externalAgentConfig/import/completed"; readonly params: ServerNotification__ExternalAgentConfigImportCompletedNotification; + readonly emittedAtMs?: number; + } + | { + readonly method: "fs/changed"; + readonly params: ServerNotification__FsChangedNotification; + readonly emittedAtMs?: number; } - | { readonly method: "fs/changed"; readonly params: ServerNotification__FsChangedNotification } | { readonly method: "item/reasoning/summaryTextDelta"; readonly params: ServerNotification__ReasoningSummaryTextDeltaNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/reasoning/summaryPartAdded"; readonly params: ServerNotification__ReasoningSummaryPartAddedNotification; + readonly emittedAtMs?: number; } | { readonly method: "item/reasoning/textDelta"; readonly params: ServerNotification__ReasoningTextDeltaNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/compacted"; readonly params: ServerNotification__ContextCompactedNotification; + readonly emittedAtMs?: number; } | { readonly method: "model/rerouted"; readonly params: ServerNotification__ModelReroutedNotification; + readonly emittedAtMs?: number; } | { readonly method: "model/verification"; readonly params: ServerNotification__ModelVerificationNotification; + readonly emittedAtMs?: number; } | { readonly method: "turn/moderationMetadata"; readonly params: ServerNotification__TurnModerationMetadataNotification; + readonly emittedAtMs?: number; + } + | { + readonly method: "model/safetyBuffering/updated"; + readonly params: ServerNotification__ModelSafetyBufferingUpdatedNotification; + readonly emittedAtMs?: number; + } + | { + readonly method: "warning"; + readonly params: ServerNotification__WarningNotification; + readonly emittedAtMs?: number; } - | { readonly method: "warning"; readonly params: ServerNotification__WarningNotification } | { readonly method: "guardianWarning"; readonly params: ServerNotification__GuardianWarningNotification; + readonly emittedAtMs?: number; } | { readonly method: "deprecationNotice"; readonly params: ServerNotification__DeprecationNoticeNotification; + readonly emittedAtMs?: number; } | { readonly method: "configWarning"; readonly params: ServerNotification__ConfigWarningNotification; + readonly emittedAtMs?: number; } | { readonly method: "fuzzyFileSearch/sessionUpdated"; readonly params: ServerNotification__FuzzyFileSearchSessionUpdatedNotification; + readonly emittedAtMs?: number; } | { readonly method: "fuzzyFileSearch/sessionCompleted"; readonly params: ServerNotification__FuzzyFileSearchSessionCompletedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/realtime/started"; readonly params: ServerNotification__ThreadRealtimeStartedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/realtime/itemAdded"; readonly params: ServerNotification__ThreadRealtimeItemAddedNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/realtime/transcript/delta"; readonly params: ServerNotification__ThreadRealtimeTranscriptDeltaNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/realtime/transcript/done"; readonly params: ServerNotification__ThreadRealtimeTranscriptDoneNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/realtime/outputAudio/delta"; readonly params: ServerNotification__ThreadRealtimeOutputAudioDeltaNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/realtime/sdp"; readonly params: ServerNotification__ThreadRealtimeSdpNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/realtime/error"; readonly params: ServerNotification__ThreadRealtimeErrorNotification; + readonly emittedAtMs?: number; } | { readonly method: "thread/realtime/closed"; readonly params: ServerNotification__ThreadRealtimeClosedNotification; + readonly emittedAtMs?: number; } | { readonly method: "windows/worldWritableWarning"; readonly params: ServerNotification__WindowsWorldWritableWarningNotification; + readonly emittedAtMs?: number; } | { readonly method: "windowsSandbox/setupCompleted"; readonly params: ServerNotification__WindowsSandboxSetupCompletedNotification; + readonly emittedAtMs?: number; } | { readonly method: "account/login/completed"; readonly params: ServerNotification__AccountLoginCompletedNotification; + readonly emittedAtMs?: number; }; export const ServerNotification = Schema.Union( [ Schema.Struct({ method: Schema.Literal("error").annotate({ title: "ErrorNotificationMethod" }), params: ServerNotification__ErrorNotification, - }).annotate({ title: "ErrorNotification", description: "NEW NOTIFICATIONS" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/started").annotate({ title: "Thread/startedNotificationMethod", }), params: ServerNotification__ThreadStartedNotification, - }).annotate({ title: "Thread/startedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/status/changed").annotate({ title: "Thread/status/changedNotificationMethod", }), params: ServerNotification__ThreadStatusChangedNotification, - }).annotate({ title: "Thread/status/changedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/archived").annotate({ title: "Thread/archivedNotificationMethod", }), params: ServerNotification__ThreadArchivedNotification, - }).annotate({ title: "Thread/archivedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/deleted").annotate({ title: "Thread/deletedNotificationMethod", }), params: ServerNotification__ThreadDeletedNotification, - }).annotate({ title: "Thread/deletedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/unarchived").annotate({ title: "Thread/unarchivedNotificationMethod", }), params: ServerNotification__ThreadUnarchivedNotification, - }).annotate({ title: "Thread/unarchivedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/closed").annotate({ title: "Thread/closedNotificationMethod", }), params: ServerNotification__ThreadClosedNotification, - }).annotate({ title: "Thread/closedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("skills/changed").annotate({ title: "Skills/changedNotificationMethod", }), params: ServerNotification__SkillsChangedNotification, - }).annotate({ title: "Skills/changedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/name/updated").annotate({ title: "Thread/name/updatedNotificationMethod", }), params: ServerNotification__ThreadNameUpdatedNotification, - }).annotate({ title: "Thread/name/updatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/goal/updated").annotate({ title: "Thread/goal/updatedNotificationMethod", }), params: ServerNotification__ThreadGoalUpdatedNotification, - }).annotate({ title: "Thread/goal/updatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/goal/cleared").annotate({ title: "Thread/goal/clearedNotificationMethod", }), params: ServerNotification__ThreadGoalClearedNotification, - }).annotate({ title: "Thread/goal/clearedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), + Schema.Struct({ + method: Schema.Literal("thread/environment/connected").annotate({ + title: "Thread/environment/connectedNotificationMethod", + }), + params: ServerNotification__EnvironmentConnectionNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), + Schema.Struct({ + method: Schema.Literal("thread/environment/disconnected").annotate({ + title: "Thread/environment/disconnectedNotificationMethod", + }), + params: ServerNotification__EnvironmentConnectionNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/settings/updated").annotate({ title: "Thread/settings/updatedNotificationMethod", }), params: ServerNotification__ThreadSettingsUpdatedNotification, - }).annotate({ title: "Thread/settings/updatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/tokenUsage/updated").annotate({ title: "Thread/tokenUsage/updatedNotificationMethod", }), params: ServerNotification__ThreadTokenUsageUpdatedNotification, - }).annotate({ title: "Thread/tokenUsage/updatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("turn/started").annotate({ title: "Turn/startedNotificationMethod" }), params: ServerNotification__TurnStartedNotification, - }).annotate({ title: "Turn/startedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("hook/started").annotate({ title: "Hook/startedNotificationMethod" }), params: ServerNotification__HookStartedNotification, - }).annotate({ title: "Hook/startedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("turn/completed").annotate({ title: "Turn/completedNotificationMethod", }), params: ServerNotification__TurnCompletedNotification, - }).annotate({ title: "Turn/completedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("hook/completed").annotate({ title: "Hook/completedNotificationMethod", }), params: ServerNotification__HookCompletedNotification, - }).annotate({ title: "Hook/completedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("turn/diff/updated").annotate({ title: "Turn/diff/updatedNotificationMethod", }), params: ServerNotification__TurnDiffUpdatedNotification, - }).annotate({ title: "Turn/diff/updatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("turn/plan/updated").annotate({ title: "Turn/plan/updatedNotificationMethod", }), params: ServerNotification__TurnPlanUpdatedNotification, - }).annotate({ title: "Turn/plan/updatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/started").annotate({ title: "Item/startedNotificationMethod" }), params: ServerNotification__ItemStartedNotification, - }).annotate({ title: "Item/startedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/autoApprovalReview/started").annotate({ title: "Item/autoApprovalReview/startedNotificationMethod", }), params: ServerNotification__ItemGuardianApprovalReviewStartedNotification, - }).annotate({ title: "Item/autoApprovalReview/startedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/autoApprovalReview/completed").annotate({ title: "Item/autoApprovalReview/completedNotificationMethod", }), params: ServerNotification__ItemGuardianApprovalReviewCompletedNotification, - }).annotate({ title: "Item/autoApprovalReview/completedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/completed").annotate({ title: "Item/completedNotificationMethod", }), params: ServerNotification__ItemCompletedNotification, - }).annotate({ title: "Item/completedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/agentMessage/delta").annotate({ title: "Item/agentMessage/deltaNotificationMethod", }), params: ServerNotification__AgentMessageDeltaNotification, - }).annotate({ title: "Item/agentMessage/deltaNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/plan/delta").annotate({ title: "Item/plan/deltaNotificationMethod", }), params: ServerNotification__PlanDeltaNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), }).annotate({ - title: "Item/plan/deltaNotification", - description: "EXPERIMENTAL - proposed plan streaming deltas for plan items.", + title: "ServerNotification", + description: "Notification sent from the server to the client.", }), Schema.Struct({ method: Schema.Literal("command/exec/outputDelta").annotate({ title: "Command/exec/outputDeltaNotificationMethod", }), params: ServerNotification__CommandExecOutputDeltaNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), }).annotate({ - title: "Command/exec/outputDeltaNotification", - description: - "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", + title: "ServerNotification", + description: "Notification sent from the server to the client.", }), Schema.Struct({ method: Schema.Literal("process/outputDelta").annotate({ title: "Process/outputDeltaNotificationMethod", }), params: ServerNotification__ProcessOutputDeltaNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), }).annotate({ - title: "Process/outputDeltaNotification", - description: - "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.", + title: "ServerNotification", + description: "Notification sent from the server to the client.", }), Schema.Struct({ method: Schema.Literal("process/exited").annotate({ title: "Process/exitedNotificationMethod", }), params: ServerNotification__ProcessExitedNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), }).annotate({ - title: "Process/exitedNotification", - description: "Final exit notification for a `process/spawn` session.", + title: "ServerNotification", + description: "Notification sent from the server to the client.", }), Schema.Struct({ method: Schema.Literal("item/commandExecution/outputDelta").annotate({ title: "Item/commandExecution/outputDeltaNotificationMethod", }), params: ServerNotification__CommandExecutionOutputDeltaNotification, - }).annotate({ title: "Item/commandExecution/outputDeltaNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/commandExecution/terminalInteraction").annotate({ title: "Item/commandExecution/terminalInteractionNotificationMethod", }), params: ServerNotification__TerminalInteractionNotification, - }).annotate({ title: "Item/commandExecution/terminalInteractionNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/fileChange/outputDelta").annotate({ title: "Item/fileChange/outputDeltaNotificationMethod", }), params: ServerNotification__FileChangeOutputDeltaNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), }).annotate({ - title: "Item/fileChange/outputDeltaNotification", - description: "Deprecated legacy apply_patch output stream notification.", + title: "ServerNotification", + description: "Notification sent from the server to the client.", }), Schema.Struct({ method: Schema.Literal("item/fileChange/patchUpdated").annotate({ title: "Item/fileChange/patchUpdatedNotificationMethod", }), params: ServerNotification__FileChangePatchUpdatedNotification, - }).annotate({ title: "Item/fileChange/patchUpdatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("serverRequest/resolved").annotate({ title: "ServerRequest/resolvedNotificationMethod", }), params: ServerNotification__ServerRequestResolvedNotification, - }).annotate({ title: "ServerRequest/resolvedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/mcpToolCall/progress").annotate({ title: "Item/mcpToolCall/progressNotificationMethod", }), params: ServerNotification__McpToolCallProgressNotification, - }).annotate({ title: "Item/mcpToolCall/progressNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("mcpServer/oauthLogin/completed").annotate({ title: "McpServer/oauthLogin/completedNotificationMethod", }), params: ServerNotification__McpServerOauthLoginCompletedNotification, - }).annotate({ title: "McpServer/oauthLogin/completedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("mcpServer/startupStatus/updated").annotate({ title: "McpServer/startupStatus/updatedNotificationMethod", }), params: ServerNotification__McpServerStatusUpdatedNotification, - }).annotate({ title: "McpServer/startupStatus/updatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("account/updated").annotate({ title: "Account/updatedNotificationMethod", }), params: ServerNotification__AccountUpdatedNotification, - }).annotate({ title: "Account/updatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("account/rateLimits/updated").annotate({ title: "Account/rateLimits/updatedNotificationMethod", }), params: ServerNotification__AccountRateLimitsUpdatedNotification, - }).annotate({ title: "Account/rateLimits/updatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("app/list/updated").annotate({ title: "App/list/updatedNotificationMethod", }), params: ServerNotification__AppListUpdatedNotification, - }).annotate({ title: "App/list/updatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("remoteControl/status/changed").annotate({ title: "RemoteControl/status/changedNotificationMethod", }), params: ServerNotification__RemoteControlStatusChangedNotification, - }).annotate({ title: "RemoteControl/status/changedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), + Schema.Struct({ + method: Schema.Literal("externalAgentConfig/import/progress").annotate({ + title: "ExternalAgentConfig/import/progressNotificationMethod", + }), + params: ServerNotification__ExternalAgentConfigImportProgressNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("externalAgentConfig/import/completed").annotate({ title: "ExternalAgentConfig/import/completedNotificationMethod", }), params: ServerNotification__ExternalAgentConfigImportCompletedNotification, - }).annotate({ title: "ExternalAgentConfig/import/completedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("fs/changed").annotate({ title: "Fs/changedNotificationMethod" }), params: ServerNotification__FsChangedNotification, - }).annotate({ title: "Fs/changedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/reasoning/summaryTextDelta").annotate({ title: "Item/reasoning/summaryTextDeltaNotificationMethod", }), params: ServerNotification__ReasoningSummaryTextDeltaNotification, - }).annotate({ title: "Item/reasoning/summaryTextDeltaNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/reasoning/summaryPartAdded").annotate({ title: "Item/reasoning/summaryPartAddedNotificationMethod", }), params: ServerNotification__ReasoningSummaryPartAddedNotification, - }).annotate({ title: "Item/reasoning/summaryPartAddedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("item/reasoning/textDelta").annotate({ title: "Item/reasoning/textDeltaNotificationMethod", }), params: ServerNotification__ReasoningTextDeltaNotification, - }).annotate({ title: "Item/reasoning/textDeltaNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/compacted").annotate({ title: "Thread/compactedNotificationMethod", }), params: ServerNotification__ContextCompactedNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), }).annotate({ - title: "Thread/compactedNotification", - description: "Deprecated: Use `ContextCompaction` item type instead.", + title: "ServerNotification", + description: "Notification sent from the server to the client.", }), Schema.Struct({ method: Schema.Literal("model/rerouted").annotate({ title: "Model/reroutedNotificationMethod", }), params: ServerNotification__ModelReroutedNotification, - }).annotate({ title: "Model/reroutedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("model/verification").annotate({ title: "Model/verificationNotificationMethod", }), params: ServerNotification__ModelVerificationNotification, - }).annotate({ title: "Model/verificationNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("turn/moderationMetadata").annotate({ title: "Turn/moderationMetadataNotificationMethod", }), params: ServerNotification__TurnModerationMetadataNotification, - }).annotate({ title: "Turn/moderationMetadataNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), + Schema.Struct({ + method: Schema.Literal("model/safetyBuffering/updated").annotate({ + title: "Model/safetyBuffering/updatedNotificationMethod", + }), + params: ServerNotification__ModelSafetyBufferingUpdatedNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("warning").annotate({ title: "WarningNotificationMethod" }), params: ServerNotification__WarningNotification, - }).annotate({ title: "WarningNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("guardianWarning").annotate({ title: "GuardianWarningNotificationMethod", }), params: ServerNotification__GuardianWarningNotification, - }).annotate({ title: "GuardianWarningNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("deprecationNotice").annotate({ title: "DeprecationNoticeNotificationMethod", }), params: ServerNotification__DeprecationNoticeNotification, - }).annotate({ title: "DeprecationNoticeNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("configWarning").annotate({ title: "ConfigWarningNotificationMethod", }), params: ServerNotification__ConfigWarningNotification, - }).annotate({ title: "ConfigWarningNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("fuzzyFileSearch/sessionUpdated").annotate({ title: "FuzzyFileSearch/sessionUpdatedNotificationMethod", }), params: ServerNotification__FuzzyFileSearchSessionUpdatedNotification, - }).annotate({ title: "FuzzyFileSearch/sessionUpdatedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("fuzzyFileSearch/sessionCompleted").annotate({ title: "FuzzyFileSearch/sessionCompletedNotificationMethod", }), params: ServerNotification__FuzzyFileSearchSessionCompletedNotification, - }).annotate({ title: "FuzzyFileSearch/sessionCompletedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/realtime/started").annotate({ title: "Thread/realtime/startedNotificationMethod", }), params: ServerNotification__ThreadRealtimeStartedNotification, - }).annotate({ title: "Thread/realtime/startedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/realtime/itemAdded").annotate({ title: "Thread/realtime/itemAddedNotificationMethod", }), params: ServerNotification__ThreadRealtimeItemAddedNotification, - }).annotate({ title: "Thread/realtime/itemAddedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/realtime/transcript/delta").annotate({ title: "Thread/realtime/transcript/deltaNotificationMethod", }), params: ServerNotification__ThreadRealtimeTranscriptDeltaNotification, - }).annotate({ title: "Thread/realtime/transcript/deltaNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/realtime/transcript/done").annotate({ title: "Thread/realtime/transcript/doneNotificationMethod", }), params: ServerNotification__ThreadRealtimeTranscriptDoneNotification, - }).annotate({ title: "Thread/realtime/transcript/doneNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/realtime/outputAudio/delta").annotate({ title: "Thread/realtime/outputAudio/deltaNotificationMethod", }), params: ServerNotification__ThreadRealtimeOutputAudioDeltaNotification, - }).annotate({ title: "Thread/realtime/outputAudio/deltaNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/realtime/sdp").annotate({ title: "Thread/realtime/sdpNotificationMethod", }), params: ServerNotification__ThreadRealtimeSdpNotification, - }).annotate({ title: "Thread/realtime/sdpNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/realtime/error").annotate({ title: "Thread/realtime/errorNotificationMethod", }), params: ServerNotification__ThreadRealtimeErrorNotification, - }).annotate({ title: "Thread/realtime/errorNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("thread/realtime/closed").annotate({ title: "Thread/realtime/closedNotificationMethod", }), params: ServerNotification__ThreadRealtimeClosedNotification, - }).annotate({ title: "Thread/realtime/closedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("windows/worldWritableWarning").annotate({ title: "Windows/worldWritableWarningNotificationMethod", }), params: ServerNotification__WindowsWorldWritableWarningNotification, + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), }).annotate({ - title: "Windows/worldWritableWarningNotification", - description: - "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", + title: "ServerNotification", + description: "Notification sent from the server to the client.", }), Schema.Struct({ method: Schema.Literal("windowsSandbox/setupCompleted").annotate({ title: "WindowsSandbox/setupCompletedNotificationMethod", }), params: ServerNotification__WindowsSandboxSetupCompletedNotification, - }).annotate({ title: "WindowsSandbox/setupCompletedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), Schema.Struct({ method: Schema.Literal("account/login/completed").annotate({ title: "Account/login/completedNotificationMethod", }), params: ServerNotification__AccountLoginCompletedNotification, - }).annotate({ title: "Account/login/completedNotification" }), + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt()), + ), + }).annotate({ + title: "ServerNotification", + description: "Notification sent from the server to the client.", + }), ], { mode: "oneOf" }, -).annotate({ - title: "ServerNotification", - description: "Notification sent from the server to the client.", -}); +); export type ServerNotification__ByteRange = { readonly end: number; readonly start: number }; export const ServerNotification__ByteRange = Schema.Struct({ @@ -32157,6 +36031,21 @@ export const ServerNotification__HookSource = Schema.Literals([ "unknown", ]); +export type ServerNotification__MultiAgentMode = + | "explicitRequestOnly" + | "proactive" + | { readonly custom: string }; +export const ServerNotification__MultiAgentMode = Schema.Union( + [ + Schema.Literals(["explicitRequestOnly", "proactive"]), + Schema.Struct({ custom: Schema.String }).annotate({ title: "CustomMultiAgentMode" }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", +}); + export type ServerNotification__NetworkAccess = "restricted" | "enabled"; export const ServerNotification__NetworkAccess = Schema.Literals(["restricted", "enabled"]); @@ -32185,6 +36074,14 @@ export const ServerNotification__SessionSource = Schema.Union( { mode: "oneOf" }, ); +export type ServerNotification__ThreadExtra = {}; +export const ServerNotification__ThreadExtra = Schema.Struct({}).annotate({ + description: "Extra app-server data for a thread.", +}); + +export type ServerNotification__ThreadHistoryMode = "legacy" | "paginated"; +export const ServerNotification__ThreadHistoryMode = Schema.Literals(["legacy", "paginated"]); + export type ServerNotification__TurnItemsView = "notLoaded" | "summary" | "full"; export const ServerNotification__TurnItemsView = Schema.Literals(["notLoaded", "summary", "full"]); @@ -32412,12 +36309,21 @@ export const ServerRequest__CommandExecutionApprovalDecision = Schema.Union( ); export type ToolRequestUserInputParams = { + readonly autoResolutionMs?: number | null; readonly itemId: string; readonly questions: ReadonlyArray; readonly threadId: string; readonly turnId: string; }; export const ToolRequestUserInputParams = Schema.Struct({ + autoResolutionMs: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), itemId: Schema.String, questions: Schema.Array(ToolRequestUserInputParams__ToolRequestUserInputQuestion), threadId: Schema.String, @@ -32532,6 +36438,40 @@ export const V2AppListUpdatedNotification = Schema.Struct({ description: "EXPERIMENTAL - notification emitted when the app list changes.", }); +export type V2AppsInstalledParams = { + readonly forceRefresh?: boolean; + readonly threadId?: string | null; +}; +export const V2AppsInstalledParams = Schema.Struct({ + forceRefresh: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "When true and Apps are permitted, refresh and publish the hosted connector runtime tool snapshot first.", + }), + ), + threadId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional loaded thread id used to evaluate effective app configuration.", + }), + Schema.Null, + ]), + ), +}).annotate({ + title: "AppsInstalledParams", + description: "Read the committed installed connector runtime snapshot.", +}); + +export type V2AppsInstalledResponse = { + readonly apps: ReadonlyArray; +}; +export const V2AppsInstalledResponse = Schema.Struct({ + apps: Schema.Array(V2AppsInstalledResponse__InstalledApp), +}).annotate({ + title: "AppsInstalledResponse", + description: "The installed connectors in one committed runtime snapshot.", +}); + export type V2AppsListParams = { readonly cursor?: string | null; readonly forceRefetch?: boolean; @@ -32594,6 +36534,35 @@ export const V2AppsListResponse = Schema.Struct({ ), }).annotate({ title: "AppsListResponse", description: "EXPERIMENTAL - app list response." }); +export type V2AppsReadParams = { + readonly appIds: ReadonlyArray; + readonly includeTools?: boolean; +}; +export const V2AppsReadParams = Schema.Struct({ + appIds: Schema.Array(Schema.String).annotate({ + description: + "App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while preserving their first-request order.", + }), + includeTools: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "When true, include display-only public tool summaries in the returned metadata.", + }), + ), +}).annotate({ + title: "AppsReadParams", + description: "EXPERIMENTAL - read metadata for specific apps/connectors.", +}); + +export type V2AppsReadResponse = { + readonly apps: ReadonlyArray; + readonly missingAppIds: ReadonlyArray; +}; +export const V2AppsReadResponse = Schema.Struct({ + apps: Schema.Array(V2AppsReadResponse__ConnectorMetadata), + missingAppIds: Schema.Array(Schema.String), +}).annotate({ title: "AppsReadResponse", description: "EXPERIMENTAL - app/read response." }); + export type V2CancelLoginAccountParams = { readonly loginId: string }; export const V2CancelLoginAccountParams = Schema.Struct({ loginId: Schema.String }).annotate({ title: "CancelLoginAccountParams", @@ -33017,6 +36986,7 @@ export type V2ConfigRequirementsReadResponse__ManagedHooksRequirements = { readonly PostToolUse: ReadonlyArray; readonly PreCompact: ReadonlyArray; readonly PreToolUse: ReadonlyArray; + readonly SessionEnd?: ReadonlyArray; readonly SessionStart: ReadonlyArray; readonly Stop: ReadonlyArray; readonly SubagentStart: ReadonlyArray; @@ -33031,6 +37001,11 @@ export const V2ConfigRequirementsReadResponse__ManagedHooksRequirements = Schema PostToolUse: Schema.Array(V2ConfigRequirementsReadResponse__ConfiguredHookMatcherGroup), PreCompact: Schema.Array(V2ConfigRequirementsReadResponse__ConfiguredHookMatcherGroup), PreToolUse: Schema.Array(V2ConfigRequirementsReadResponse__ConfiguredHookMatcherGroup), + SessionEnd: Schema.optionalKey( + Schema.Array(V2ConfigRequirementsReadResponse__ConfiguredHookMatcherGroup).annotate({ + default: [], + }), + ), SessionStart: Schema.Array(V2ConfigRequirementsReadResponse__ConfiguredHookMatcherGroup), Stop: Schema.Array(V2ConfigRequirementsReadResponse__ConfiguredHookMatcherGroup), SubagentStart: Schema.Array(V2ConfigRequirementsReadResponse__ConfiguredHookMatcherGroup), @@ -33206,6 +37181,33 @@ export const V2ConfigWriteResponse = Schema.Struct({ version: Schema.String, }).annotate({ title: "ConfigWriteResponse" }); +export type V2ConsumeAccountRateLimitResetCreditParams = { + readonly creditId?: string | null; + readonly idempotencyKey: string; +}; +export const V2ConsumeAccountRateLimitResetCreditParams = Schema.Struct({ + creditId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.", + }), + Schema.Null, + ]), + ), + idempotencyKey: Schema.String.annotate({ + description: + "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.", + }), +}).annotate({ title: "ConsumeAccountRateLimitResetCreditParams" }); + +export type V2ConsumeAccountRateLimitResetCreditResponse = { + readonly outcome: V2ConsumeAccountRateLimitResetCreditResponse__ConsumeAccountRateLimitResetCreditOutcome; +}; +export const V2ConsumeAccountRateLimitResetCreditResponse = Schema.Struct({ + outcome: V2ConsumeAccountRateLimitResetCreditResponse__ConsumeAccountRateLimitResetCreditOutcome, +}).annotate({ title: "ConsumeAccountRateLimitResetCreditResponse" }); + export type V2ContextCompactedNotification = { readonly threadId: string; readonly turnId: string }; export const V2ContextCompactedNotification = Schema.Struct({ threadId: Schema.String, @@ -33231,6 +37233,15 @@ export const V2DeprecationNoticeNotification = Schema.Struct({ summary: Schema.String.annotate({ description: "Concise summary of what is deprecated." }), }).annotate({ title: "DeprecationNoticeNotification" }); +export type V2EnvironmentConnectionNotification = { + readonly environmentId: string; + readonly threadId: string; +}; +export const V2EnvironmentConnectionNotification = Schema.Struct({ + environmentId: Schema.String, + threadId: Schema.String, +}).annotate({ title: "EnvironmentConnectionNotification" }); + export type V2ErrorNotification = { readonly error: V2ErrorNotification__TurnError; readonly threadId: string; @@ -33333,6 +37344,8 @@ export const V2ExperimentalFeatureListResponse__ExperimentalFeatureStage = Schem export type V2ExternalAgentConfigDetectParams = { readonly cwds?: ReadonlyArray | null; readonly includeHome?: boolean; + readonly migrationSource?: string | null; + readonly source?: string | null; }; export const V2ExternalAgentConfigDetectParams = Schema.Struct({ cwds: Schema.optionalKey( @@ -33345,9 +37358,27 @@ export const V2ExternalAgentConfigDetectParams = Schema.Struct({ ), includeHome: Schema.optionalKey( Schema.Boolean.annotate({ - description: "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + description: "If true, include detection under the user's home directory.", }), ), + migrationSource: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional migration-source selector. Missing or unrecognized values use the default source.", + }), + Schema.Null, + ]), + ), + source: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Deprecated field retained for compatibility. This field is ignored; use `migrationSource` to select the migration source.", + }), + Schema.Null, + ]), + ), }).annotate({ title: "ExternalAgentConfigDetectParams" }); export type V2ExternalAgentConfigDetectResponse = { @@ -33357,22 +37388,71 @@ export const V2ExternalAgentConfigDetectResponse = Schema.Struct({ items: Schema.Array(V2ExternalAgentConfigDetectResponse__ExternalAgentConfigMigrationItem), }).annotate({ title: "ExternalAgentConfigDetectResponse" }); -export type V2ExternalAgentConfigImportCompletedNotification = {}; -export const V2ExternalAgentConfigImportCompletedNotification = Schema.Struct({}).annotate({ - title: "ExternalAgentConfigImportCompletedNotification", -}); +export type V2ExternalAgentConfigImportCompletedNotification = { + readonly importId: string; + readonly itemTypeResults: ReadonlyArray; +}; +export const V2ExternalAgentConfigImportCompletedNotification = Schema.Struct({ + importId: Schema.String, + itemTypeResults: Schema.Array( + V2ExternalAgentConfigImportCompletedNotification__ExternalAgentConfigImportTypeResult, + ), +}).annotate({ title: "ExternalAgentConfigImportCompletedNotification" }); + +export type V2ExternalAgentConfigImportHistoriesReadResponse = { + readonly connectors: ReadonlyArray; + readonly data: ReadonlyArray; +}; +export const V2ExternalAgentConfigImportHistoriesReadResponse = Schema.Struct({ + connectors: Schema.Array( + V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentImportedConnectorCandidate, + ), + data: Schema.Array( + V2ExternalAgentConfigImportHistoriesReadResponse__ExternalAgentConfigImportHistory, + ), +}).annotate({ title: "ExternalAgentConfigImportHistoriesReadResponse" }); export type V2ExternalAgentConfigImportParams = { readonly migrationItems: ReadonlyArray; + readonly migrationSource?: string | null; + readonly source?: string | null; }; export const V2ExternalAgentConfigImportParams = Schema.Struct({ migrationItems: Schema.Array(V2ExternalAgentConfigImportParams__ExternalAgentConfigMigrationItem), + migrationSource: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Migration-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source.", + }), + Schema.Null, + ]), + ), + source: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional identifier for the product that initiated the import.", + }), + Schema.Null, + ]), + ), }).annotate({ title: "ExternalAgentConfigImportParams" }); -export type V2ExternalAgentConfigImportResponse = {}; -export const V2ExternalAgentConfigImportResponse = Schema.Struct({}).annotate({ - title: "ExternalAgentConfigImportResponse", -}); +export type V2ExternalAgentConfigImportProgressNotification = { + readonly importId: string; + readonly itemTypeResults: ReadonlyArray; +}; +export const V2ExternalAgentConfigImportProgressNotification = Schema.Struct({ + importId: Schema.String, + itemTypeResults: Schema.Array( + V2ExternalAgentConfigImportProgressNotification__ExternalAgentConfigImportTypeResult, + ), +}).annotate({ title: "ExternalAgentConfigImportProgressNotification" }); + +export type V2ExternalAgentConfigImportResponse = { readonly importId: string }; +export const V2ExternalAgentConfigImportResponse = Schema.Struct({ + importId: Schema.String, +}).annotate({ title: "ExternalAgentConfigImportResponse" }); export type V2FeedbackUploadParams = { readonly classification: string; @@ -33735,6 +37815,7 @@ export const V2GetAccountParams = Schema.Struct({ }).annotate({ title: "GetAccountParams" }); export type V2GetAccountRateLimitsResponse = { + readonly rateLimitResetCredits?: V2GetAccountRateLimitsResponse__RateLimitResetCreditsSummary | null; readonly rateLimits: { readonly credits?: V2GetAccountRateLimitsResponse__CreditsSnapshot | null; readonly individualLimit?: V2GetAccountRateLimitsResponse__SpendControlLimitSnapshot | null; @@ -33744,12 +37825,16 @@ export type V2GetAccountRateLimitsResponse = { readonly primary?: V2GetAccountRateLimitsResponse__RateLimitWindow | null; readonly rateLimitReachedType?: V2GetAccountRateLimitsResponse__RateLimitReachedType | null; readonly secondary?: V2GetAccountRateLimitsResponse__RateLimitWindow | null; + readonly spendControlReached?: boolean | null; }; readonly rateLimitsByLimitId?: { readonly [x: string]: V2GetAccountRateLimitsResponse__RateLimitSnapshot; } | null; }; export const V2GetAccountRateLimitsResponse = Schema.Struct({ + rateLimitResetCredits: Schema.optionalKey( + Schema.Union([V2GetAccountRateLimitsResponse__RateLimitResetCreditsSummary, Schema.Null]), + ), rateLimits: Schema.Struct({ credits: Schema.optionalKey( Schema.Union([V2GetAccountRateLimitsResponse__CreditsSnapshot, Schema.Null]), @@ -33771,6 +37856,15 @@ export const V2GetAccountRateLimitsResponse = Schema.Struct({ secondary: Schema.optionalKey( Schema.Union([V2GetAccountRateLimitsResponse__RateLimitWindow, Schema.Null]), ), + spendControlReached: Schema.optionalKey( + Schema.Union([ + Schema.Boolean.annotate({ + description: + "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.", + }), + Schema.Null, + ]), + ), }).annotate({ description: "Backward-compatible single-bucket view; mirrors the historical payload.", }), @@ -33807,6 +37901,19 @@ export const V2GetAccountTokenUsageResponse = Schema.Struct({ summary: V2GetAccountTokenUsageResponse__AccountTokenUsageSummary, }).annotate({ title: "GetAccountTokenUsageResponse" }); +export type V2GetWorkspaceMessagesResponse = { + readonly featureEnabled: boolean; + readonly messages: ReadonlyArray; +}; +export const V2GetWorkspaceMessagesResponse = Schema.Struct({ + featureEnabled: Schema.Boolean.annotate({ + description: "Whether the workspace-message backend route is available for this client.", + }), + messages: Schema.Array(V2GetWorkspaceMessagesResponse__WorkspaceMessage).annotate({ + description: "Active workspace messages returned by the backend.", + }), +}).annotate({ title: "GetWorkspaceMessagesResponse" }); + export type V2GuardianWarningNotification = { readonly message: string; readonly threadId: string }; export const V2GuardianWarningNotification = Schema.Struct({ message: Schema.String.annotate({ @@ -34161,14 +38268,20 @@ export const V2ListMcpServerStatusResponse = Schema.Struct({ export type V2LoginAccountParams = | { readonly apiKey: string; readonly type: "apiKey" } - | { readonly codexStreamlinedLogin?: boolean; readonly type: "chatgpt" } + | { + readonly appBrand?: V2LoginAccountParams__LoginAppBrand | null; + readonly codexStreamlinedLogin?: boolean; + readonly type: "chatgpt"; + readonly useHostedLoginSuccessPage?: boolean; + } | { readonly type: "chatgptDeviceCode" } | { readonly accessToken: string; readonly chatgptAccountId: string; readonly chatgptPlanType?: string | null; readonly type: "chatgptAuthTokens"; - }; + } + | { readonly apiKey: string; readonly region: string; readonly type: "amazonBedrock" }; export const V2LoginAccountParams = Schema.Union( [ Schema.Struct({ @@ -34176,8 +38289,12 @@ export const V2LoginAccountParams = Schema.Union( type: Schema.Literal("apiKey").annotate({ title: "ApiKeyv2::LoginAccountParamsType" }), }).annotate({ title: "ApiKeyv2::LoginAccountParams" }), Schema.Struct({ + appBrand: Schema.optionalKey( + Schema.Union([V2LoginAccountParams__LoginAppBrand, Schema.Null]), + ), codexStreamlinedLogin: Schema.optionalKey(Schema.Boolean), type: Schema.Literal("chatgpt").annotate({ title: "Chatgptv2::LoginAccountParamsType" }), + useHostedLoginSuccessPage: Schema.optionalKey(Schema.Boolean), }).annotate({ title: "Chatgptv2::LoginAccountParams" }), Schema.Struct({ type: Schema.Literal("chatgptDeviceCode").annotate({ @@ -34209,6 +38326,16 @@ export const V2LoginAccountParams = Schema.Union( description: "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", }), + Schema.Struct({ + apiKey: Schema.String, + region: Schema.String, + type: Schema.Literal("amazonBedrock").annotate({ + title: "AmazonBedrockv2::LoginAccountParamsType", + }), + }).annotate({ + title: "AmazonBedrockv2::LoginAccountParams", + description: "[UNSTABLE] Managed Amazon Bedrock login is experimental.", + }), ], { mode: "oneOf" }, ).annotate({ title: "LoginAccountParams" }); @@ -34222,7 +38349,8 @@ export type V2LoginAccountResponse = readonly userCode: string; readonly verificationUrl: string; } - | { readonly type: "chatgptAuthTokens" }; + | { readonly type: "chatgptAuthTokens" } + | { readonly type: "amazonBedrock" }; export const V2LoginAccountResponse = Schema.Union( [ Schema.Struct({ @@ -34253,6 +38381,11 @@ export const V2LoginAccountResponse = Schema.Union( title: "ChatgptAuthTokensv2::LoginAccountResponseType", }), }).annotate({ title: "ChatgptAuthTokensv2::LoginAccountResponse" }), + Schema.Struct({ + type: Schema.Literal("amazonBedrock").annotate({ + title: "AmazonBedrockv2::LoginAccountResponseType", + }), + }).annotate({ title: "AmazonBedrockv2::LoginAccountResponse" }), ], { mode: "oneOf" }, ).annotate({ title: "LoginAccountResponse" }); @@ -34338,21 +38471,25 @@ export type V2McpServerOauthLoginCompletedNotification = { readonly error?: string | null; readonly name: string; readonly success: boolean; + readonly threadId?: string | null; }; export const V2McpServerOauthLoginCompletedNotification = Schema.Struct({ error: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, success: Schema.Boolean, + threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }).annotate({ title: "McpServerOauthLoginCompletedNotification" }); export type V2McpServerOauthLoginParams = { readonly name: string; readonly scopes?: ReadonlyArray | null; + readonly threadId?: string | null; readonly timeoutSecs?: number | null; }; export const V2McpServerOauthLoginParams = Schema.Struct({ name: Schema.String, scopes: Schema.optionalKey(Schema.Union([Schema.Array(Schema.String), Schema.Null])), + threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), timeoutSecs: Schema.optionalKey( Schema.Union([Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), Schema.Null]), ), @@ -34370,12 +38507,19 @@ export const V2McpServerRefreshResponse = Schema.Struct({}).annotate({ export type V2McpServerStatusUpdatedNotification = { readonly error?: string | null; + readonly failureReason?: V2McpServerStatusUpdatedNotification__McpServerStartupFailureReason | null; readonly name: string; readonly status: V2McpServerStatusUpdatedNotification__McpServerStartupState; readonly threadId?: string | null; }; export const V2McpServerStatusUpdatedNotification = Schema.Struct({ error: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + failureReason: Schema.optionalKey( + Schema.Union([ + V2McpServerStatusUpdatedNotification__McpServerStartupFailureReason, + Schema.Null, + ]), + ), name: Schema.String, status: V2McpServerStatusUpdatedNotification__McpServerStartupState, threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), @@ -34505,6 +38649,25 @@ export const V2ModelReroutedNotification = Schema.Struct({ turnId: Schema.String, }).annotate({ title: "ModelReroutedNotification" }); +export type V2ModelSafetyBufferingUpdatedNotification = { + readonly fasterModel?: string | null; + readonly model: string; + readonly reasons: ReadonlyArray; + readonly showBufferingUi: boolean; + readonly threadId: string; + readonly turnId: string; + readonly useCases: ReadonlyArray; +}; +export const V2ModelSafetyBufferingUpdatedNotification = Schema.Struct({ + fasterModel: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + model: Schema.String, + reasons: Schema.Array(Schema.String), + showBufferingUi: Schema.Boolean, + threadId: Schema.String, + turnId: Schema.String, + useCases: Schema.Array(Schema.String), +}).annotate({ title: "ModelSafetyBufferingUpdatedNotification" }); + export type V2ModelVerificationNotification = { readonly threadId: string; readonly turnId: string; @@ -34905,6 +39068,25 @@ export const V2ProcessOutputDeltaNotification__ProcessOutputStream = Schema.Lite "stderr", ]).annotate({ description: "Stream label for `process/outputDelta` notifications." }); +export type V2RawResponseCompletedNotification = { + readonly responseId: string; + readonly threadId: string; + readonly turnId: string; + readonly usage?: V2RawResponseCompletedNotification__TokenUsageBreakdown | null; +}; +export const V2RawResponseCompletedNotification = Schema.Struct({ + responseId: Schema.String, + threadId: Schema.String, + turnId: Schema.String, + usage: Schema.optionalKey( + Schema.Union([V2RawResponseCompletedNotification__TokenUsageBreakdown, Schema.Null]), + ), +}).annotate({ + title: "RawResponseCompletedNotification", + description: + "Internal-only notification containing the exact usage from one upstream Responses API completion.", +}); + export type V2RawResponseItemCompletedNotification = { readonly item: V2RawResponseItemCompletedNotification__ResponseItem; readonly threadId: string; @@ -35226,6 +39408,7 @@ export type V2ThreadForkParams = { readonly cwd?: string | null; readonly developerInstructions?: string | null; readonly ephemeral?: boolean; + readonly lastTurnId?: string | null; readonly model?: string | null; readonly modelProvider?: string | null; readonly sandbox?: V2ThreadForkParams__SandboxMode | null; @@ -35250,6 +39433,15 @@ export const V2ThreadForkParams = Schema.Struct({ cwd: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), developerInstructions: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), ephemeral: Schema.optionalKey(Schema.Boolean), + lastTurnId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.", + }), + Schema.Null, + ]), + ), model: Schema.optionalKey( Schema.Union([ Schema.String.annotate({ @@ -35284,7 +39476,7 @@ export type V2ThreadForkResponse = { readonly approvalPolicy: V2ThreadForkResponse__AskForApproval; readonly approvalsReviewer: "user" | "auto_review" | "guardian_subagent"; readonly cwd: V2ThreadForkResponse__AbsolutePathBuf; - readonly instructionSources?: ReadonlyArray; + readonly instructionSources?: ReadonlyArray; readonly model: string; readonly modelProvider: string; readonly reasoningEffort?: V2ThreadForkResponse__ReasoningEffort | null; @@ -35310,8 +39502,9 @@ export const V2ThreadForkResponse = Schema.Struct({ }), cwd: V2ThreadForkResponse__AbsolutePathBuf, instructionSources: Schema.optionalKey( - Schema.Array(V2ThreadForkResponse__AbsolutePathBuf).annotate({ - description: "Instruction source files currently loaded for this thread.", + Schema.Array(V2ThreadForkResponse__LegacyAppPathString).annotate({ + description: + "Environment-native paths to instruction source files currently loaded for this thread.", default: [], }), ), @@ -35433,6 +39626,21 @@ export const V2ThreadForkResponse__CommandExecutionSource = Schema.Literals([ "unifiedExecInteraction", ]); +export type V2ThreadForkResponse__MultiAgentMode = + | "explicitRequestOnly" + | "proactive" + | { readonly custom: string }; +export const V2ThreadForkResponse__MultiAgentMode = Schema.Union( + [ + Schema.Literals(["explicitRequestOnly", "proactive"]), + Schema.Struct({ custom: Schema.String }).annotate({ title: "CustomMultiAgentMode" }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", +}); + export type V2ThreadForkResponse__NetworkAccess = "restricted" | "enabled"; export const V2ThreadForkResponse__NetworkAccess = Schema.Literals(["restricted", "enabled"]); @@ -35498,6 +39706,14 @@ export const V2ThreadForkResponse__SessionSource = Schema.Union( { mode: "oneOf" }, ); +export type V2ThreadForkResponse__ThreadExtra = {}; +export const V2ThreadForkResponse__ThreadExtra = Schema.Struct({}).annotate({ + description: "Extra app-server data for a thread.", +}); + +export type V2ThreadForkResponse__ThreadHistoryMode = "legacy" | "paginated"; +export const V2ThreadForkResponse__ThreadHistoryMode = Schema.Literals(["legacy", "paginated"]); + export type V2ThreadForkResponse__ThreadStatus = | { readonly type: "notLoaded" } | { readonly type: "idle" } @@ -35786,6 +40002,14 @@ export const V2ThreadListResponse__SessionSource = Schema.Union( { mode: "oneOf" }, ); +export type V2ThreadListResponse__ThreadExtra = {}; +export const V2ThreadListResponse__ThreadExtra = Schema.Struct({}).annotate({ + description: "Extra app-server data for a thread.", +}); + +export type V2ThreadListResponse__ThreadHistoryMode = "legacy" | "paginated"; +export const V2ThreadListResponse__ThreadHistoryMode = Schema.Literals(["legacy", "paginated"]); + export type V2ThreadListResponse__ThreadStatus = | { readonly type: "notLoaded" } | { readonly type: "idle" } @@ -35957,6 +40181,17 @@ export const V2ThreadMetadataUpdateResponse__SessionSource = Schema.Union( { mode: "oneOf" }, ); +export type V2ThreadMetadataUpdateResponse__ThreadExtra = {}; +export const V2ThreadMetadataUpdateResponse__ThreadExtra = Schema.Struct({}).annotate({ + description: "Extra app-server data for a thread.", +}); + +export type V2ThreadMetadataUpdateResponse__ThreadHistoryMode = "legacy" | "paginated"; +export const V2ThreadMetadataUpdateResponse__ThreadHistoryMode = Schema.Literals([ + "legacy", + "paginated", +]); + export type V2ThreadMetadataUpdateResponse__ThreadStatus = | { readonly type: "notLoaded" } | { readonly type: "idle" } @@ -36077,6 +40312,14 @@ export const V2ThreadReadResponse__SessionSource = Schema.Union( { mode: "oneOf" }, ); +export type V2ThreadReadResponse__ThreadExtra = {}; +export const V2ThreadReadResponse__ThreadExtra = Schema.Struct({}).annotate({ + description: "Extra app-server data for a thread.", +}); + +export type V2ThreadReadResponse__ThreadHistoryMode = "legacy" | "paginated"; +export const V2ThreadReadResponse__ThreadHistoryMode = Schema.Literals(["legacy", "paginated"]); + export type V2ThreadReadResponse__ThreadStatus = | { readonly type: "notLoaded" } | { readonly type: "idle" } @@ -36271,6 +40514,7 @@ export type V2ThreadResumeParams__ResponseItem = | { readonly content: ReadonlyArray; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly phase?: V2ThreadResumeParams__MessagePhase | null; readonly role: string; readonly type: "message"; @@ -36278,12 +40522,16 @@ export type V2ThreadResumeParams__ResponseItem = | { readonly author: string; readonly content: ReadonlyArray; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly recipient: string; readonly type: "agent_message"; } | { readonly content?: ReadonlyArray | null; readonly encrypted_content?: string | null; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly summary: ReadonlyArray; readonly type: "reasoning"; } @@ -36291,6 +40539,7 @@ export type V2ThreadResumeParams__ResponseItem = readonly action: V2ThreadResumeParams__LocalShellAction; readonly call_id?: string | null; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly status: V2ThreadResumeParams__LocalShellStatus; readonly type: "local_shell_call"; } @@ -36298,6 +40547,7 @@ export type V2ThreadResumeParams__ResponseItem = readonly arguments: string; readonly call_id: string; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly name: string; readonly namespace?: string | null; readonly type: "function_call"; @@ -36307,11 +40557,14 @@ export type V2ThreadResumeParams__ResponseItem = readonly call_id?: string | null; readonly execution: string; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly status?: string | null; readonly type: "tool_search_call"; } | { readonly call_id: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly output: V2ThreadResumeParams__FunctionCallOutputBody; readonly type: "function_call_output"; } @@ -36319,12 +40572,16 @@ export type V2ThreadResumeParams__ResponseItem = readonly call_id: string; readonly id?: string | null; readonly input: string; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly name: string; + readonly namespace?: string | null; readonly status?: string | null; readonly type: "custom_tool_call"; } | { readonly call_id: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly name?: string | null; readonly output: V2ThreadResumeParams__FunctionCallOutputBody; readonly type: "custom_tool_call_output"; @@ -36332,6 +40589,8 @@ export type V2ThreadResumeParams__ResponseItem = | { readonly call_id?: string | null; readonly execution: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly status: string; readonly tools: ReadonlyArray; readonly type: "tool_search_output"; @@ -36339,26 +40598,39 @@ export type V2ThreadResumeParams__ResponseItem = | { readonly action?: V2ThreadResumeParams__ResponsesApiWebSearchAction | null; readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly status?: string | null; readonly type: "web_search_call"; } | { - readonly id: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; readonly result: string; readonly revised_prompt?: string | null; readonly status: string; readonly type: "image_generation_call"; } - | { readonly encrypted_content: string; readonly type: "compaction" } + | { + readonly encrypted_content: string; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; + readonly type: "compaction"; + } | { readonly type: "compaction_trigger" } - | { readonly encrypted_content?: string | null; readonly type: "context_compaction" } + | { + readonly encrypted_content?: string | null; + readonly id?: string | null; + readonly internal_chat_message_metadata_passthrough?: V2ThreadResumeParams__InternalChatMessageMetadataPassthrough | null; + readonly type: "context_compaction"; + } | { readonly type: "other" }; export const V2ThreadResumeParams__ResponseItem = Schema.Union( [ Schema.Struct({ content: Schema.Array(V2ThreadResumeParams__ContentItem), - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), ), phase: Schema.optionalKey(Schema.Union([V2ThreadResumeParams__MessagePhase, Schema.Null])), role: Schema.String, @@ -36367,6 +40639,10 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( Schema.Struct({ author: Schema.String, content: Schema.Array(V2ThreadResumeParams__AgentMessageInputContent), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), recipient: Schema.String, type: Schema.Literal("agent_message").annotate({ title: "AgentMessageResponseItemType" }), }).annotate({ title: "AgentMessageResponseItem" }), @@ -36375,6 +40651,10 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( Schema.Union([Schema.Array(V2ThreadResumeParams__ReasoningItemContent), Schema.Null]), ), encrypted_content: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), summary: Schema.Array(V2ThreadResumeParams__ReasoningItemReasoningSummary), type: Schema.Literal("reasoning").annotate({ title: "ReasoningResponseItemType" }), }).annotate({ title: "ReasoningResponseItem" }), @@ -36390,11 +40670,13 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( Schema.Union([ Schema.String.annotate({ description: "Legacy id field retained for compatibility with older payloads.", - writeOnly: true, }), Schema.Null, ]), ), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), status: V2ThreadResumeParams__LocalShellStatus, type: Schema.Literal("local_shell_call").annotate({ title: "LocalShellCallResponseItemType", @@ -36403,8 +40685,9 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( Schema.Struct({ arguments: Schema.String, call_id: Schema.String, - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), ), name: Schema.String, namespace: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), @@ -36414,8 +40697,9 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( arguments: Schema.Unknown, call_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), execution: Schema.String, - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), ), status: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.Literal("tool_search_call").annotate({ @@ -36424,6 +40708,10 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( }).annotate({ title: "ToolSearchCallResponseItem" }), Schema.Struct({ call_id: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), output: V2ThreadResumeParams__FunctionCallOutputBody, type: Schema.Literal("function_call_output").annotate({ title: "FunctionCallOutputResponseItemType", @@ -36431,11 +40719,13 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( }).annotate({ title: "FunctionCallOutputResponseItem" }), Schema.Struct({ call_id: Schema.String, - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), - ), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), input: Schema.String, + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), name: Schema.String, + namespace: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), status: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.Literal("custom_tool_call").annotate({ title: "CustomToolCallResponseItemType", @@ -36443,6 +40733,10 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( }).annotate({ title: "CustomToolCallResponseItem" }), Schema.Struct({ call_id: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), name: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), output: V2ThreadResumeParams__FunctionCallOutputBody, type: Schema.Literal("custom_tool_call_output").annotate({ @@ -36452,6 +40746,10 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( Schema.Struct({ call_id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), execution: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), status: Schema.String, tools: Schema.Array(Schema.Unknown), type: Schema.Literal("tool_search_output").annotate({ @@ -36462,14 +40760,18 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( action: Schema.optionalKey( Schema.Union([V2ThreadResumeParams__ResponsesApiWebSearchAction, Schema.Null]), ), - id: Schema.optionalKey( - Schema.Union([Schema.String.annotate({ writeOnly: true }), Schema.Null]), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), ), status: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.Literal("web_search_call").annotate({ title: "WebSearchCallResponseItemType" }), }).annotate({ title: "WebSearchCallResponseItem" }), Schema.Struct({ - id: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), result: Schema.String, revised_prompt: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), status: Schema.String, @@ -36479,6 +40781,10 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( }).annotate({ title: "ImageGenerationCallResponseItem" }), Schema.Struct({ encrypted_content: Schema.String, + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), type: Schema.Literal("compaction").annotate({ title: "CompactionResponseItemType" }), }).annotate({ title: "CompactionResponseItem" }), Schema.Struct({ @@ -36488,6 +40794,10 @@ export const V2ThreadResumeParams__ResponseItem = Schema.Union( }).annotate({ title: "CompactionTriggerResponseItem" }), Schema.Struct({ encrypted_content: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + internal_chat_message_metadata_passthrough: Schema.optionalKey( + Schema.Union([V2ThreadResumeParams__InternalChatMessageMetadataPassthrough, Schema.Null]), + ), type: Schema.Literal("context_compaction").annotate({ title: "ContextCompactionResponseItemType", }), @@ -36529,7 +40839,7 @@ export type V2ThreadResumeResponse = { readonly approvalPolicy: V2ThreadResumeResponse__AskForApproval; readonly approvalsReviewer: "user" | "auto_review" | "guardian_subagent"; readonly cwd: V2ThreadResumeResponse__AbsolutePathBuf; - readonly instructionSources?: ReadonlyArray; + readonly instructionSources?: ReadonlyArray; readonly model: string; readonly modelProvider: string; readonly reasoningEffort?: V2ThreadResumeResponse__ReasoningEffort | null; @@ -36555,8 +40865,9 @@ export const V2ThreadResumeResponse = Schema.Struct({ }), cwd: V2ThreadResumeResponse__AbsolutePathBuf, instructionSources: Schema.optionalKey( - Schema.Array(V2ThreadResumeResponse__AbsolutePathBuf).annotate({ - description: "Instruction source files currently loaded for this thread.", + Schema.Array(V2ThreadResumeResponse__LegacyAppPathString).annotate({ + description: + "Environment-native paths to instruction source files currently loaded for this thread.", default: [], }), ), @@ -36684,6 +40995,21 @@ export const V2ThreadResumeResponse__CommandExecutionSource = Schema.Literals([ "unifiedExecInteraction", ]); +export type V2ThreadResumeResponse__MultiAgentMode = + | "explicitRequestOnly" + | "proactive" + | { readonly custom: string }; +export const V2ThreadResumeResponse__MultiAgentMode = Schema.Union( + [ + Schema.Literals(["explicitRequestOnly", "proactive"]), + Schema.Struct({ custom: Schema.String }).annotate({ title: "CustomMultiAgentMode" }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", +}); + export type V2ThreadResumeResponse__NetworkAccess = "restricted" | "enabled"; export const V2ThreadResumeResponse__NetworkAccess = Schema.Literals(["restricted", "enabled"]); @@ -36749,6 +41075,14 @@ export const V2ThreadResumeResponse__SessionSource = Schema.Union( { mode: "oneOf" }, ); +export type V2ThreadResumeResponse__ThreadExtra = {}; +export const V2ThreadResumeResponse__ThreadExtra = Schema.Struct({}).annotate({ + description: "Extra app-server data for a thread.", +}); + +export type V2ThreadResumeResponse__ThreadHistoryMode = "legacy" | "paginated"; +export const V2ThreadResumeResponse__ThreadHistoryMode = Schema.Literals(["legacy", "paginated"]); + export type V2ThreadResumeResponse__ThreadStatus = | { readonly type: "notLoaded" } | { readonly type: "idle" } @@ -36804,7 +41138,10 @@ export const V2ThreadRollbackParams = Schema.Struct({ .check(Schema.isInt()) .check(Schema.isGreaterThanOrEqualTo(0)), threadId: Schema.String, -}).annotate({ title: "ThreadRollbackParams" }); +}).annotate({ + title: "ThreadRollbackParams", + description: "DEPRECATED: `thread/rollback` will be removed soon.", +}); export type V2ThreadRollbackResponse = { readonly thread: { @@ -36822,6 +41159,7 @@ export type V2ThreadRollbackResponse = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -36890,7 +41228,9 @@ export const V2ThreadRollbackResponse = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -36918,6 +41258,15 @@ export const V2ThreadRollbackResponse = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -37050,6 +41399,7 @@ export type V2ThreadRollbackResponse__Thread = { readonly parentThreadId?: string | null; readonly path?: string | null; readonly preview: string; + readonly recencyAt?: number | null; readonly sessionId: string; readonly source: | "cli" @@ -37116,7 +41466,9 @@ export const V2ThreadRollbackResponse__Thread = Schema.Struct({ description: "Optional Git metadata captured when the thread was created.", }), ), - id: Schema.String, + id: Schema.String.annotate({ + description: "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + }), modelProvider: Schema.String.annotate({ description: "Model provider used for this thread (for example, 'openai').", }), @@ -37144,6 +41496,15 @@ export const V2ThreadRollbackResponse__Thread = Schema.Struct({ preview: Schema.String.annotate({ description: "Usually the first user message in the thread, if available.", }), + recencyAt: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Unix timestamp (in seconds) used for thread recency ordering.", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), sessionId: Schema.String.annotate({ description: "Session id shared by threads that belong to the same session tree.", }), @@ -37192,6 +41553,14 @@ export const V2ThreadRollbackResponse__Thread = Schema.Struct({ }).check(Schema.isInt()), }); +export type V2ThreadRollbackResponse__ThreadExtra = {}; +export const V2ThreadRollbackResponse__ThreadExtra = Schema.Struct({}).annotate({ + description: "Extra app-server data for a thread.", +}); + +export type V2ThreadRollbackResponse__ThreadHistoryMode = "legacy" | "paginated"; +export const V2ThreadRollbackResponse__ThreadHistoryMode = Schema.Literals(["legacy", "paginated"]); + export type V2ThreadRollbackResponse__ThreadStatus = | { readonly type: "notLoaded" } | { readonly type: "idle" } @@ -37246,6 +41615,21 @@ export const V2ThreadSettingsUpdatedNotification = Schema.Struct({ threadSettings: V2ThreadSettingsUpdatedNotification__ThreadSettings, }).annotate({ title: "ThreadSettingsUpdatedNotification" }); +export type V2ThreadSettingsUpdatedNotification__MultiAgentMode = + | "explicitRequestOnly" + | "proactive" + | { readonly custom: string }; +export const V2ThreadSettingsUpdatedNotification__MultiAgentMode = Schema.Union( + [ + Schema.Literals(["explicitRequestOnly", "proactive"]), + Schema.Struct({ custom: Schema.String }).annotate({ title: "CustomMultiAgentMode" }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", +}); + export type V2ThreadSettingsUpdatedNotification__NetworkAccess = "restricted" | "enabled"; export const V2ThreadSettingsUpdatedNotification__NetworkAccess = Schema.Literals([ "restricted", @@ -37339,6 +41723,17 @@ export const V2ThreadStartedNotification__SessionSource = Schema.Union( { mode: "oneOf" }, ); +export type V2ThreadStartedNotification__ThreadExtra = {}; +export const V2ThreadStartedNotification__ThreadExtra = Schema.Struct({}).annotate({ + description: "Extra app-server data for a thread.", +}); + +export type V2ThreadStartedNotification__ThreadHistoryMode = "legacy" | "paginated"; +export const V2ThreadStartedNotification__ThreadHistoryMode = Schema.Literals([ + "legacy", + "paginated", +]); + export type V2ThreadStartedNotification__ThreadStatus = | { readonly type: "notLoaded" } | { readonly type: "idle" } @@ -37423,6 +41818,12 @@ export const V2ThreadStartParams = Schema.Struct({ ), }).annotate({ title: "ThreadStartParams" }); +export type V2ThreadStartParams__AbsolutePathBuf = string; +export const V2ThreadStartParams__AbsolutePathBuf = Schema.String.annotate({ + description: + "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", +}); + export type V2ThreadStartParams__CapabilityRootLocation = { readonly environmentId: string; readonly path: string; @@ -37432,7 +41833,9 @@ export const V2ThreadStartParams__CapabilityRootLocation = Schema.Union( [ Schema.Struct({ environmentId: Schema.String, - path: Schema.String, + path: Schema.String.annotate({ + description: "Absolute path for the root in the selected environment.", + }), type: Schema.Literal("environment").annotate({ title: "EnvironmentCapabilityRootLocationType", }), @@ -37444,19 +41847,52 @@ export const V2ThreadStartParams__CapabilityRootLocation = Schema.Union( { mode: "oneOf" }, ).annotate({ description: "Location used to resolve a selected capability root." }); -export type V2ThreadStartParams__DynamicToolSpec = { - readonly deferLoading?: boolean; - readonly description: string; - readonly inputSchema: unknown; - readonly name: string; - readonly namespace?: string | null; -}; -export const V2ThreadStartParams__DynamicToolSpec = Schema.Struct({ - deferLoading: Schema.optionalKey(Schema.Boolean), - description: Schema.String, - inputSchema: Schema.Unknown, - name: Schema.String, - namespace: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +export type V2ThreadStartParams__DynamicToolSpec = + | { + readonly deferLoading?: boolean; + readonly description: string; + readonly inputSchema: unknown; + readonly name: string; + readonly type: "function"; + } + | { + readonly description: string; + readonly name: string; + readonly tools: ReadonlyArray; + readonly type: "namespace"; + }; +export const V2ThreadStartParams__DynamicToolSpec = Schema.Union( + [ + Schema.Struct({ + deferLoading: Schema.optionalKey(Schema.Boolean), + description: Schema.String, + inputSchema: Schema.Unknown, + name: Schema.String, + type: Schema.Literal("function").annotate({ title: "FunctionDynamicToolSpecType" }), + }).annotate({ title: "FunctionDynamicToolSpec" }), + Schema.Struct({ + description: Schema.String, + name: Schema.String, + tools: Schema.Array(V2ThreadStartParams__DynamicToolNamespaceTool), + type: Schema.Literal("namespace").annotate({ title: "NamespaceDynamicToolSpecType" }), + }).annotate({ title: "NamespaceDynamicToolSpec" }), + ], + { mode: "oneOf" }, +); + +export type V2ThreadStartParams__MultiAgentMode = + | "explicitRequestOnly" + | "proactive" + | { readonly custom: string }; +export const V2ThreadStartParams__MultiAgentMode = Schema.Union( + [ + Schema.Literals(["explicitRequestOnly", "proactive"]), + Schema.Struct({ custom: Schema.String }).annotate({ title: "CustomMultiAgentMode" }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", }); export type V2ThreadStartParams__SelectedCapabilityRoot = { @@ -37475,7 +41911,9 @@ export const V2ThreadStartParams__SelectedCapabilityRoot = Schema.Struct({ [ Schema.Struct({ environmentId: Schema.String, - path: Schema.String, + path: Schema.String.annotate({ + description: "Absolute path for the root in the selected environment.", + }), type: Schema.Literal("environment").annotate({ title: "EnvironmentCapabilityRootLocationType", }), @@ -37490,20 +41928,32 @@ export const V2ThreadStartParams__SelectedCapabilityRoot = Schema.Struct({ description: "A user-selected root that can expose one or more runtime capabilities.", }); +export type V2ThreadStartParams__ThreadHistoryMode = "legacy" | "paginated"; +export const V2ThreadStartParams__ThreadHistoryMode = Schema.Literals(["legacy", "paginated"]); + export type V2ThreadStartParams__TurnEnvironmentParams = { - readonly cwd: V2ThreadStartParams__AbsolutePathBuf; + readonly cwd: V2ThreadStartParams__LegacyAppPathString; readonly environmentId: string; + readonly runtimeWorkspaceRoots?: ReadonlyArray | null; }; export const V2ThreadStartParams__TurnEnvironmentParams = Schema.Struct({ - cwd: V2ThreadStartParams__AbsolutePathBuf, + cwd: V2ThreadStartParams__LegacyAppPathString, environmentId: Schema.String, + runtimeWorkspaceRoots: Schema.optionalKey( + Schema.Union([ + Schema.Array(V2ThreadStartParams__LegacyAppPathString).annotate({ + description: "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", + }), + Schema.Null, + ]), + ), }); export type V2ThreadStartResponse = { readonly approvalPolicy: V2ThreadStartResponse__AskForApproval; readonly approvalsReviewer: "user" | "auto_review" | "guardian_subagent"; readonly cwd: V2ThreadStartResponse__AbsolutePathBuf; - readonly instructionSources?: ReadonlyArray; + readonly instructionSources?: ReadonlyArray; readonly model: string; readonly modelProvider: string; readonly reasoningEffort?: V2ThreadStartResponse__ReasoningEffort | null; @@ -37529,8 +41979,9 @@ export const V2ThreadStartResponse = Schema.Struct({ }), cwd: V2ThreadStartResponse__AbsolutePathBuf, instructionSources: Schema.optionalKey( - Schema.Array(V2ThreadStartResponse__AbsolutePathBuf).annotate({ - description: "Instruction source files currently loaded for this thread.", + Schema.Array(V2ThreadStartResponse__LegacyAppPathString).annotate({ + description: + "Environment-native paths to instruction source files currently loaded for this thread.", default: [], }), ), @@ -37655,6 +42106,21 @@ export const V2ThreadStartResponse__CommandExecutionSource = Schema.Literals([ "unifiedExecInteraction", ]); +export type V2ThreadStartResponse__MultiAgentMode = + | "explicitRequestOnly" + | "proactive" + | { readonly custom: string }; +export const V2ThreadStartResponse__MultiAgentMode = Schema.Union( + [ + Schema.Literals(["explicitRequestOnly", "proactive"]), + Schema.Struct({ custom: Schema.String }).annotate({ title: "CustomMultiAgentMode" }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", +}); + export type V2ThreadStartResponse__NetworkAccess = "restricted" | "enabled"; export const V2ThreadStartResponse__NetworkAccess = Schema.Literals(["restricted", "enabled"]); @@ -37720,6 +42186,14 @@ export const V2ThreadStartResponse__SessionSource = Schema.Union( { mode: "oneOf" }, ); +export type V2ThreadStartResponse__ThreadExtra = {}; +export const V2ThreadStartResponse__ThreadExtra = Schema.Struct({}).annotate({ + description: "Extra app-server data for a thread.", +}); + +export type V2ThreadStartResponse__ThreadHistoryMode = "legacy" | "paginated"; +export const V2ThreadStartResponse__ThreadHistoryMode = Schema.Literals(["legacy", "paginated"]); + export type V2ThreadStartResponse__ThreadStatus = | { readonly type: "notLoaded" } | { readonly type: "idle" } @@ -37854,6 +42328,17 @@ export const V2ThreadUnarchiveResponse__SessionSource = Schema.Union( { mode: "oneOf" }, ); +export type V2ThreadUnarchiveResponse__ThreadExtra = {}; +export const V2ThreadUnarchiveResponse__ThreadExtra = Schema.Struct({}).annotate({ + description: "Extra app-server data for a thread.", +}); + +export type V2ThreadUnarchiveResponse__ThreadHistoryMode = "legacy" | "paginated"; +export const V2ThreadUnarchiveResponse__ThreadHistoryMode = Schema.Literals([ + "legacy", + "paginated", +]); + export type V2ThreadUnarchiveResponse__ThreadStatus = | { readonly type: "notLoaded" } | { readonly type: "idle" } @@ -38187,16 +42672,40 @@ export const V2TurnStartParams__CollaborationMode = Schema.Struct({ settings: V2TurnStartParams__Settings, }).annotate({ description: "Collaboration mode for a Codex session." }); +export type V2TurnStartParams__MultiAgentMode = + | "explicitRequestOnly" + | "proactive" + | { readonly custom: string }; +export const V2TurnStartParams__MultiAgentMode = Schema.Union( + [ + Schema.Literals(["explicitRequestOnly", "proactive"]), + Schema.Struct({ custom: Schema.String }).annotate({ title: "CustomMultiAgentMode" }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", +}); + export type V2TurnStartParams__NetworkAccess = "restricted" | "enabled"; export const V2TurnStartParams__NetworkAccess = Schema.Literals(["restricted", "enabled"]); export type V2TurnStartParams__TurnEnvironmentParams = { - readonly cwd: V2TurnStartParams__AbsolutePathBuf; + readonly cwd: V2TurnStartParams__LegacyAppPathString; readonly environmentId: string; + readonly runtimeWorkspaceRoots?: ReadonlyArray | null; }; export const V2TurnStartParams__TurnEnvironmentParams = Schema.Struct({ - cwd: V2TurnStartParams__AbsolutePathBuf, + cwd: V2TurnStartParams__LegacyAppPathString, environmentId: Schema.String, + runtimeWorkspaceRoots: Schema.optionalKey( + Schema.Union([ + Schema.Array(V2TurnStartParams__LegacyAppPathString).annotate({ + description: "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", + }), + Schema.Null, + ]), + ), }); export type V2TurnStartResponse = { readonly turn: V2TurnStartResponse__Turn }; diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index 0ed81b3b9e6..a68abd537a8 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -23,6 +23,15 @@ const decodeJson = Schema.decodeEffect(Schema.UnknownFromJsonString); const decodeAccountTokenUsageResponse = Schema.decodeUnknownEffect( CodexRpc.CLIENT_REQUEST_RESPONSES["account/usage/read"], ); +const decodeAccountRateLimitsResponse = Schema.decodeUnknownEffect( + CodexRpc.CLIENT_REQUEST_RESPONSES["account/rateLimits/read"], +); +const decodeConsumeRateLimitResetCreditParams = Schema.decodeUnknownEffect( + CodexRpc.CLIENT_REQUEST_PARAMS["account/rateLimitResetCredit/consume"], +); +const decodeConsumeRateLimitResetCreditResponse = Schema.decodeUnknownEffect( + CodexRpc.CLIENT_REQUEST_RESPONSES["account/rateLimitResetCredit/consume"], +); it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { it.effect("maps account usage responses to the upstream token usage schema", () => @@ -42,6 +51,83 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect("maps earned rate-limit reset credits from account rate-limit snapshots", () => + Effect.gen(function* () { + assert.strictEqual( + CodexRpc.CLIENT_REQUEST_RESPONSES["account/rateLimits/read"], + CodexSchema.V2GetAccountRateLimitsResponse, + ); + + const response = { + rateLimits: {}, + rateLimitResetCredits: { + availableCount: 2, + credits: [ + { + id: "RateLimitResetCredit_1", + resetType: "codexRateLimits", + status: "available", + grantedAt: 1_781_654_400, + expiresAt: 1_784_246_400, + title: "Full reset", + description: "Ready to redeem", + }, + { + id: "RateLimitResetCredit_2", + resetType: "unknown", + status: "unknown", + grantedAt: 1_781_654_401, + expiresAt: null, + }, + ], + }, + } as const; + + assert.deepEqual(yield* decodeAccountRateLimitsResponse(response), response); + assert.deepEqual( + yield* decodeAccountRateLimitsResponse({ + rateLimits: {}, + rateLimitResetCredits: { availableCount: 2, credits: null }, + }), + { + rateLimits: {}, + rateLimitResetCredits: { availableCount: 2, credits: null }, + }, + ); + }), + ); + + it.effect("maps the earned rate-limit reset consume request and response", () => + Effect.gen(function* () { + assert.equal( + CodexRpc.CLIENT_REQUEST_METHODS["account/rateLimitResetCredit/consume"], + "account/rateLimitResetCredit/consume", + ); + assert.strictEqual( + CodexRpc.CLIENT_REQUEST_PARAMS["account/rateLimitResetCredit/consume"], + CodexSchema.V2ConsumeAccountRateLimitResetCreditParams, + ); + assert.strictEqual( + CodexRpc.CLIENT_REQUEST_RESPONSES["account/rateLimitResetCredit/consume"], + CodexSchema.V2ConsumeAccountRateLimitResetCreditResponse, + ); + + assert.deepEqual( + yield* decodeConsumeRateLimitResetCreditParams({ + idempotencyKey: "8ae96ff3-3425-4f4c-8772-b6fd61502868", + creditId: "RateLimitResetCredit_1", + }), + { + idempotencyKey: "8ae96ff3-3425-4f4c-8772-b6fd61502868", + creditId: "RateLimitResetCredit_1", + }, + ); + assert.deepEqual(yield* decodeConsumeRateLimitResetCreditResponse({ outcome: "reset" }), { + outcome: "reset", + }); + }), + ); + it.effect( "encodes requests without a jsonrpc field and routes inbound requests and notifications", () => From 2fae0d0ac300353be774c9f9fe79d3af025d4f91 Mon Sep 17 00:00:00 2001 From: Chris Michael Guzman <67719167+Chrrxs@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:49:22 -0400 Subject: [PATCH 016/110] fix(preview): preserve direct localhost navigation (#3939) Co-authored-by: Julius Marminge Co-authored-by: codex --- .../components/preview/PreviewView.test.tsx | 194 ++++++++++++++++++ .../src/components/preview/PreviewView.tsx | 48 +++-- 2 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/preview/PreviewView.test.tsx diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx new file mode 100644 index 00000000000..c0d503c7599 --- /dev/null +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -0,0 +1,194 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(async (_tabId: string, _url: string): Promise => undefined), + rememberPreviewUrl: vi.fn(), + readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), + submittedUrl: null as ((url: string) => void) | null, + emptyStateUrl: null as ((url: string) => void) | null, + showEmptyState: false, +})); + +vi.mock("~/state/session", () => ({ + readPreparedConnection: mocks.readPreparedConnection, +})); + +vi.mock("~/composerDraftStore", () => ({ + useComposerDraftStore: ( + select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown, + ) => select({ addPreviewAnnotation: vi.fn(), addImage: vi.fn() }), +})); + +vi.mock("~/lib/previewAnnotation", () => ({ + previewAnnotationScreenshotFile: vi.fn(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: vi.fn(), +})); + +vi.mock("~/previewStateStore", () => ({ + rememberPreviewUrl: mocks.rememberPreviewUrl, + updatePreviewServerSnapshot: vi.fn(), + useThreadPreviewState: () => ({ + activeTabId: "tab-1", + desktopByTabId: { + "tab-1": { + canGoBack: false, + canGoForward: false, + loading: false, + zoomFactor: 1, + controller: "none", + }, + }, + recentlySeenUrls: [], + sessions: mocks.showEmptyState + ? {} + : { + "tab-1": { + threadId: "thread-1", + tabId: "tab-1", + navStatus: { + _tag: "Success", + url: "http://example.com/", + title: "Example", + }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-07-13T00:00:00.000Z", + }, + }, + }), +})); + +vi.mock("~/state/environments", () => ({ + useEnvironment: () => ({ label: "WSL" }), + useEnvironmentHttpBaseUrl: () => "http://172.25.85.75:3773", +})); + +vi.mock("~/state/preview", () => ({ + previewEnvironment: { open: {}, resize: {} }, +})); + +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: () => vi.fn(), +})); + +vi.mock("~/browser/browserRecording", () => ({ + startBrowserRecording: vi.fn(), + stopBrowserRecording: vi.fn(), + useActiveBrowserRecordingTabId: () => null, +})); + +vi.mock("~/browser/browserSurfaceStore", () => ({ + useBrowserSurfaceStore: ( + select: (state: { byTabId: Record }) => unknown, + ) => select({ byTabId: {} }), +})); + +vi.mock("~/components/ui/toast", () => ({ + stackedThreadToast: vi.fn(), + toastManager: { add: vi.fn() }, +})); + +vi.mock("./previewBridge", () => ({ + previewBridge: { navigate: mocks.navigate }, +})); + +vi.mock("./PreviewChromeRow", () => ({ + PreviewChromeRow: (props: { onSubmit: (url: string) => void }) => { + mocks.submittedUrl = props.onSubmit; + return null; + }, +})); + +vi.mock("./PreviewEmptyState", () => ({ + PreviewEmptyState: (props: { onOpenUrl: (url: string) => void }) => { + mocks.emptyStateUrl = props.onOpenUrl; + return null; + }, +})); +vi.mock("./PreviewMoreMenu", () => ({ PreviewMoreMenu: () => null })); +vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); +vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null })); +vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); +vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); +vi.mock("./useLoadingProgress", () => ({ useLoadingProgress: () => 0 })); +vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); + +import { PreviewView } from "./PreviewView"; + +describe("PreviewView navigation", () => { + beforeEach(() => { + mocks.navigate.mockClear(); + mocks.rememberPreviewUrl.mockClear(); + mocks.readPreparedConnection.mockClear(); + mocks.submittedUrl = null; + mocks.emptyStateUrl = null; + mocks.showEmptyState = false; + }); + + it.each([ + [ + "https://localhost:8000/dashboard?mode=test#top", + "https://localhost:8000/dashboard?mode=test#top", + ], + ["localhost:5173/app", "http://localhost:5173/app"], + ])("preserves a direct localhost URL in a WSL environment", async (submitted, expected) => { + renderToStaticMarkup( + , + ); + + expect(mocks.submittedUrl).not.toBeNull(); + mocks.submittedUrl?.(submitted); + + await vi.waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith("tab-1", expected)); + expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith( + { + environmentId: "environment-1", + threadId: "thread-1", + }, + expected, + ); + }); + + it("maps an empty-state localhost server onto the WSL host", async () => { + mocks.showEmptyState = true; + renderToStaticMarkup( + , + ); + + expect(mocks.emptyStateUrl).not.toBeNull(); + mocks.emptyStateUrl?.("http://localhost:5173/app?mode=test#top"); + + await vi.waitFor(() => + expect(mocks.navigate).toHaveBeenCalledWith( + "tab-1", + "http://172.25.85.75:5173/app?mode=test#top", + ), + ); + expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith( + { + environmentId: "environment-1", + threadId: "thread-1", + }, + "http://172.25.85.75:5173/app?mode=test#top", + ); + }); +}); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index bb1c4d409eb..5f7deeb0fcd 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -7,6 +7,7 @@ import { type PreviewViewportSetting, type ScopedThreadRef, } from "@t3tools/contracts"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { useCallback, useEffect, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore"; @@ -112,27 +113,44 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, tabId ? (state.byTabId[tabId]?.rect ?? null) : null, ); + const navigateToResolvedUrl = useCallback( + async (resolvedUrl: string) => { + if (tabId && previewBridge) { + // Drive the webview imperatively; `usePreviewBridge` mirrors the + // resolved URL back to the server so other clients stay in sync. + await previewBridge.navigate(tabId, resolvedUrl); + rememberPreviewUrl(threadRef, resolvedUrl); + } else { + await openPreviewSession({ + openPreview: open, + threadRef, + url: resolvedUrl, + }); + } + }, + [open, tabId, threadRef], + ); + const handleSubmitUrl = useCallback( async (next: string) => { try { - const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next); - if (tabId && previewBridge) { - // Drive the webview imperatively; `usePreviewBridge` mirrors the - // resolved URL back to the server so other clients stay in sync. - await previewBridge.navigate(tabId, resolvedUrl); - rememberPreviewUrl(threadRef, resolvedUrl); - } else { - await openPreviewSession({ - openPreview: open, - threadRef, - url: resolvedUrl, - }); - } + await navigateToResolvedUrl(normalizePreviewUrl(next)); } catch { // Server-side `failed` event renders the unreachable view. } }, - [open, tabId, threadRef], + [navigateToResolvedUrl], + ); + + const handleOpenServerUrl = useCallback( + async (next: string) => { + try { + await navigateToResolvedUrl(resolveDiscoveredServerUrl(threadRef.environmentId, next)); + } catch { + // Server-side `failed` event renders the unreachable view. + } + }, + [navigateToResolvedUrl, threadRef.environmentId], ); const handleRefresh = useCallback(() => { @@ -613,7 +631,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, environmentId={threadRef.environmentId} configuredUrls={configuredUrls} recentlySeenUrls={previewState.recentlySeenUrls} - onOpenUrl={(next) => void handleSubmitUrl(next)} + onOpenUrl={(next) => void handleOpenServerUrl(next)} /> ) : null} {snapshot && desktopOverlay ? ( From 8e3467fe60c49ee739b278229156108032b72e25 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 11:54:59 +0200 Subject: [PATCH 017/110] Synchronize mobile threads with authoritative shell snapshots (#4163) Co-authored-by: codex --- apps/mobile/src/Stack.tsx | 4 +- .../src/components/CompactBrandTitle.tsx | 60 ------ .../src/features/home/HomeRouteScreen.tsx | 5 +- apps/mobile/src/features/home/HomeScreen.tsx | 11 +- .../home/WorkspaceConnectionStatus.test.ts | 17 ++ .../home/WorkspaceConnectionStatus.tsx | 7 +- .../home/workspace-connection-status.ts | 4 + .../threads/sidebar-navigation-shell.tsx | 4 +- apps/server/src/server.test.ts | 109 +++++++++++ apps/server/src/ws.ts | 54 +++++- packages/client-runtime/src/rpc/client.ts | 140 ++++++++------ .../src/state/shell-sync.test.ts | 142 +++++++++++++- packages/client-runtime/src/state/shell.ts | 113 +++++++---- .../src/state/threads-sync.test.ts | 175 +++++++++++++++++- packages/client-runtime/src/state/threads.ts | 147 ++++++++++----- packages/contracts/src/orchestration.ts | 16 ++ packages/contracts/src/server.ts | 4 + 17 files changed, 779 insertions(+), 233 deletions(-) delete mode 100644 apps/mobile/src/components/CompactBrandTitle.tsx diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index db244d7c726..4eb4a5061e8 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,7 +15,6 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; -import { renderCompactBrandTitle } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; @@ -378,8 +377,7 @@ export const RootStack = createNativeStackNavigator({ ...GLASS_HEADER_OPTIONS, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, - headerTitle: renderCompactBrandTitle, - title: "T3 Code", + title: "Threads", }, }), Thread: createNativeStackScreen({ diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx deleted file mode 100644 index 2ecf34aa40c..00000000000 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { View } from "react-native"; - -import { AppText as Text } from "./AppText"; -import { T3Wordmark } from "./T3Wordmark"; -import { useThemeColor } from "../lib/useThemeColor"; - -/** - * Compact brand lockup sized for native navigation bars. - */ -export function CompactBrandTitle() { - const iconColor = useThemeColor("--color-icon"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const subtleColor = useThemeColor("--color-subtle"); - - return ( - - - - Code - - - - Alpha - - - - ); -} - -export function renderCompactBrandTitle() { - return ; -} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index cd9467c774e..49cf06d85ec 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -4,7 +4,6 @@ import { useNavigation } from "@react-navigation/native"; import { useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { renderCompactBrandTitle } from "../../components/CompactBrandTitle"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -87,9 +86,7 @@ export function HomeRouteScreen() { > <> {/* Restore the compact title in case the split branch blanked it. */} - + - {emptyState.loading ? ( + {emptyState.loading && !shouldShowConnectionStatus ? ( ) : null} + {shouldShowConnectionStatus && Platform.OS === "ios" ? ( + + + + ) : null} {connectionStatus} diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts index 767f0e3dd3f..8c3c873cc9e 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts @@ -67,4 +67,21 @@ describe("workspace connection status", () => { expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); }); + + it("shows shell catch-up while cached threads remain visible", () => { + const state = workspaceState({ hasPendingShellSnapshot: true }); + + expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); + expect(workspaceConnectionStatusLabel(state)).toBe("Syncing threads..."); + }); + + it("distinguishes initial shell loading from cached catch-up", () => { + const state = workspaceState({ + hasLoadedShellSnapshot: false, + hasPendingShellSnapshot: true, + }); + + expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); + expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); + }); }); diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx index 8acdacfbbb3..1e986ad1a50 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx @@ -12,7 +12,10 @@ export function WorkspaceConnectionStatus(props: { readonly variant?: "floating" | "sidebar"; }) { const iconColor = useThemeColor("--color-icon-muted"); - const isReconnecting = props.state.connectingEnvironments.length > 0; + const isSynchronizing = + props.state.networkStatus !== "offline" && + props.state.connectionError === null && + (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot); const variant = props.variant ?? "floating"; return ( @@ -37,7 +40,7 @@ export function WorkspaceConnectionStatus(props: { : undefined } > - {isReconnecting ? ( + {isSynchronizing ? ( ) : ( diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index 7d2c6d840d4..d8eed4383b1 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -5,6 +5,7 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool state.networkStatus === "offline" || state.connectionError !== null || state.hasConnectingEnvironment || + state.hasPendingShellSnapshot || (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment) ); } @@ -18,5 +19,8 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string { return `Reconnecting ${state.connectingEnvironments.length} environments`; } if (state.connectionError !== null) return state.connectionError; + if (state.hasPendingShellSnapshot) { + return state.hasLoadedShellSnapshot ? "Syncing threads..." : "Loading threads..."; + } return "Not connected"; } diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index a94c033fa4d..f1e06926d0b 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -11,7 +11,6 @@ import { import type { ReactNode } from "react"; import { Platform, useColorScheme } from "react-native"; -import { renderCompactBrandTitle } from "../../components/CompactBrandTitle"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -35,11 +34,10 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { headerShadowVisible: false, headerShown: true, headerStyle: { backgroundColor: "transparent" }, - headerTitle: renderCompactBrandTitle, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: true, scrollEdgeEffects: SCROLL_EDGE_EFFECTS, - title: "T3 Code", + title: "Threads", unstable_navigationItemStyle: "editor", }; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b1f6c1f7251..a0f41cbf0fc 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3717,6 +3717,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); assert.equal(response.auth.policy, "desktop-managed-local"); + assert.equal(response.shellResumeCompletionMarker, true); + assert.equal(response.threadResumeCompletionMarker, true); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -5607,6 +5609,113 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("marks an empty shell catch-up replay as synchronized when requested", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + readEvents: () => Stream.empty, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const firstItem = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.runHead), + ), + ); + + assert.deepEqual(Option.getOrThrow(firstItem), { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("marks a socket thread snapshot as synchronized when requested", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.succeed(Option.some({ snapshotSequence: 1, thread })), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual(items[1], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("buffers shell events published while the fallback snapshot loads", () => + Effect.gen(function* () { + const liveEvents = yield* PubSub.unbounded(); + const deletedEvent = { + sequence: 2, + eventId: EventId.make("event-shell-thread-deleted"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.deleted", + payload: { + threadId: defaultThreadId, + deletedAt: "2026-01-01T00:00:01.000Z", + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publish(liveEvents, deletedEvent); + return { + snapshotSequence: 1, + projects: [], + threads: [makeDefaultOrchestrationThreadShell()], + updatedAt: "2026-01-01T00:00:00.000Z", + }; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + requestCompletionMarker: true, + }).pipe(Stream.take(3), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "thread-removed"); + assert.deepEqual(items[2], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("buffers thread events published while the initial snapshot loads", () => Effect.gen(function* () { const thread = makeDefaultOrchestrationReadModel().threads[0]!; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c7223d09dc5..41c29fc3388 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -936,6 +936,8 @@ const makeWsRpcLayer = ( otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, }, settings, + shellResumeCompletionMarker: true, + threadResumeCompletionMarker: true, }; }); @@ -1105,11 +1107,28 @@ const makeWsRpcLayer = ( }), ), ); - return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)); + const afterCatchUp = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.fromEffect( + Queue.offer(liveBuffer, { kind: "synchronized" as const }), + ).pipe(Stream.drain), + Stream.fromQueue(liveBuffer), + ) + : Stream.fromQueue(liveBuffer); + return Stream.concat(catchUpStream, afterCatchUp); }), ); } + // The full-snapshot fallback needs the same replay-window safety + // as the resume path: subscribe before loading the projection so + // events published while the snapshot is read are buffered. + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), + ); + const bufferedLiveStream = Stream.fromQueue(liveBuffer); const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( Effect.tapError((cause) => Effect.logError("orchestration shell snapshot load failed", { cause }), @@ -1123,12 +1142,21 @@ const makeWsRpcLayer = ( ), ); + const afterSnapshot = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.fromEffect( + Queue.offer(liveBuffer, { kind: "synchronized" as const }), + ).pipe(Stream.drain), + bufferedLiveStream, + ) + : bufferedLiveStream; return Stream.concat( Stream.make({ kind: "snapshot" as const, snapshot, }), - liveStream, + afterSnapshot, ); }), { "rpc.aggregate": "orchestration" }, @@ -1207,7 +1235,16 @@ const makeWsRpcLayer = ( }), ), ); - return Stream.concat(catchUpStream, bufferedLiveStream); + const afterCatchUp = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.fromEffect( + Queue.offer(liveBuffer, { kind: "synchronized" as const }), + ).pipe(Stream.drain), + bufferedLiveStream, + ) + : bufferedLiveStream; + return Stream.concat(catchUpStream, afterCatchUp); } const snapshot = yield* projectionSnapshotQuery @@ -1229,12 +1266,21 @@ const makeWsRpcLayer = ( }); } + const afterSnapshot = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.fromEffect( + Queue.offer(liveBuffer, { kind: "synchronized" as const }), + ).pipe(Stream.drain), + bufferedLiveStream, + ) + : bufferedLiveStream; return Stream.concat( Stream.make({ kind: "snapshot" as const, snapshot: snapshot.value, }), - bufferedLiveStream, + afterSnapshot, ); }), { "rpc.aggregate": "orchestration" }, diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 92892431e45..c7c928b3c95 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -11,6 +11,7 @@ import { RpcClientError } from "effect/unstable/rpc"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass()( "EnvironmentRpcUnavailableError", @@ -147,15 +148,18 @@ export function runStream( ); } -export function subscribe( +interface SubscriptionOptions { + readonly onExpectedFailure?: ( + cause: Cause.Cause>, + ) => Effect.Effect; + readonly retryExpectedFailureAfter?: Duration.Input; + readonly resubscribe?: Stream.Stream; +} + +export function subscribeDynamic( tag: TTag, - input: EnvironmentRpcInput, - options?: { - readonly onExpectedFailure?: ( - cause: Cause.Cause>, - ) => Effect.Effect; - readonly retryExpectedFailureAfter?: Duration.Input; - }, + makeInput: (session: RpcSession) => Effect.Effect>, + options?: SubscriptionOptions, ): Stream.Stream< EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure, @@ -163,8 +167,18 @@ export function subscribe( > { return Stream.unwrap( EnvironmentSupervisor.pipe( - Effect.map((supervisor) => - SubscriptionRef.changes(supervisor.session).pipe( + Effect.map((supervisor) => { + const sessionChanges = SubscriptionRef.changes(supervisor.session); + const sessions = + options?.resubscribe === undefined + ? sessionChanges + : Stream.merge( + sessionChanges, + options.resubscribe.pipe( + Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)), + ), + ); + return sessions.pipe( Stream.switchMap( Option.match({ onNone: () => Stream.empty, @@ -180,54 +194,64 @@ export function subscribe( EnvironmentRpcStreamFailure > => Stream.suspend(() => - method(input).pipe( - Stream.catchCause((cause) => { - const hasOnlyExpectedFailures = - cause.reasons.length > 0 && - cause.reasons.every((reason) => reason._tag === "Fail"); - const isTransportFailure = - hasOnlyExpectedFailures && - cause.reasons.every( - (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), - ); - if (isTransportFailure) { - return Stream.fromEffect( - Effect.logWarning( - "Durable RPC subscription lost its transport; waiting for the next session.", - { - cause: Cause.pretty(cause), - method: tag, - environmentId: supervisor.target.environmentId, - }, - ), - ).pipe(Stream.drain); - } - if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { - const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( - Stream.drain, - ); - if (options.retryExpectedFailureAfter === undefined) { - return handled; - } - return handled.pipe( - Stream.concat( - Stream.fromEffect( - Effect.sleep(options.retryExpectedFailureAfter), - ).pipe(Stream.drain), - ), - Stream.concat(subscribeToSession()), - ); - } - return Stream.failCause(cause); - }), + Stream.unwrap( + makeInput(session).pipe( + Effect.map((input) => + method(input).pipe( + Stream.catchCause((cause) => { + const hasOnlyExpectedFailures = + cause.reasons.length > 0 && + cause.reasons.every((reason) => reason._tag === "Fail"); + const isTransportFailure = + hasOnlyExpectedFailures && + cause.reasons.every( + (reason) => + reason._tag === "Fail" && isRpcClientError(reason.error), + ); + if (isTransportFailure) { + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription lost its transport; waiting for the next session.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe(Stream.drain); + } + if ( + hasOnlyExpectedFailures && + options?.onExpectedFailure !== undefined + ) { + const handled = Stream.fromEffect( + options.onExpectedFailure(cause), + ).pipe(Stream.drain); + if (options.retryExpectedFailureAfter === undefined) { + return handled; + } + return handled.pipe( + Stream.concat( + Stream.fromEffect( + Effect.sleep(options.retryExpectedFailureAfter), + ).pipe(Stream.drain), + ), + Stream.concat(subscribeToSession()), + ); + } + return Stream.failCause(cause); + }), + ), + ), + ), ), ); return subscribeToSession(); }, }), ), - ), - ), + ); + }), ), ).pipe( Stream.withSpan("EnvironmentRpc.subscribe", { @@ -236,6 +260,18 @@ export function subscribe( ); } +export function subscribe( + tag: TTag, + input: EnvironmentRpcInput, + options?: SubscriptionOptions, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribeDynamic(tag, () => Effect.succeed(input), options); +} + export const config = Effect.gen(function* () { const session = yield* currentSession(); return yield* session.initialConfig; diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index b6eb4e9f710..a4514d5f0e6 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -17,6 +18,7 @@ import { type PreparedConnection, } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as ConnectionWakeups from "../connection/wakeups.ts"; import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; @@ -48,7 +50,7 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, - initialConfig: Effect.never, + initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -112,6 +114,15 @@ describe("environment shell synchronization", () => { kind: "snapshot", snapshot: LIVE_SHELL_SNAPSHOT, }); + const synchronizing = yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => state.status === "synchronizing" && Option.isSome(state.snapshot)), + Stream.runHead, + ); + expect(Option.getOrThrow(Option.getOrThrow(synchronizing).snapshot)).toEqual( + LIVE_SHELL_SNAPSHOT, + ); + + yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((state) => state.status === "live"), Stream.runHead, @@ -137,21 +148,32 @@ describe("environment shell synchronization", () => { }), ); - it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () => + it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 5, projects: [], - threads: [], + threads: [{ id: "stale-thread" } as never], updatedAt: "2026-06-06T00:00:00.000Z", }; + const httpSnapshot: OrchestrationShellSnapshot = { + ...cachedSnapshot, + snapshotSequence: 9, + threads: [], + updatedAt: "2026-06-07T00:00:00.000Z", + }; const events = yield* Queue.unbounded(); const capturedAfterSequence = yield* SubscriptionRef.make(undefined); + const capturedCompletionMarker = yield* Ref.make(undefined); const loaderCalls = yield* SubscriptionRef.make(0); const client = { - [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }) => Stream.unwrap( - SubscriptionRef.set(capturedAfterSequence, input.afterSequence).pipe( + Ref.set(capturedCompletionMarker, input.requestCompletionMarker).pipe( + Effect.andThen(SubscriptionRef.set(capturedAfterSequence, input.afterSequence)), Effect.as(Stream.fromQueue(events)), ), ), @@ -183,9 +205,11 @@ describe("environment shell synchronization", () => { }); const snapshotLoader = ShellSnapshotLoader.of({ load: () => - SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), + SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe( + Effect.as(Option.some(httpSnapshot)), + ), }); - yield* makeEnvironmentShellState().pipe( + const shellState = yield* makeEnvironmentShellState().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService(ShellSnapshotLoader, snapshotLoader), @@ -197,8 +221,108 @@ describe("environment shell synchronization", () => { Stream.runHead, ); - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5); - expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0); + expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9); + expect(yield* Ref.get(capturedCompletionMarker)).toBe(true); + expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); + const synchronizing = yield* SubscriptionRef.get(shellState); + expect(synchronizing.status).toBe("synchronizing"); + expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); + + yield* Queue.offer(events, { kind: "synchronized" }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((value) => value.status === "live"), + Stream.runHead, + ); + }), + ); + + it.effect("refreshes the authoritative shell snapshot when the app becomes active", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const wakeups = yield* Queue.unbounded(); + const loaderCalls = yield* Ref.make(0); + const subscriptionCount = yield* Ref.make(0); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: () => + Stream.unwrap( + Ref.update(subscriptionCount, (count) => count + 1).pipe( + Effect.as(Stream.fromQueue(events)), + ), + ), + } as unknown as WsRpcProtocolClient; + const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.some(LIVE_SHELL_SNAPSHOT)), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.of({ + load: () => + Ref.updateAndGet(loaderCalls, (count) => count + 1).pipe( + Effect.map((count) => + Option.some({ ...LIVE_SHELL_SNAPSHOT, snapshotSequence: count * 10 }), + ), + ), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader, snapshotLoader), + Effect.provideService( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }), + ), + ); + + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (value) => + value.status === "synchronizing" && + Option.isSome(value.snapshot) && + value.snapshot.value.snapshotSequence === 10, + ), + Stream.runHead, + ); + yield* Queue.offer(events, { kind: "synchronized" }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((value) => value.status === "live"), + Stream.runHead, + ); + + yield* Queue.offer(wakeups, "application-active"); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (value) => + value.status === "synchronizing" && + Option.isSome(value.snapshot) && + value.snapshot.value.snapshotSequence === 20, + ), + Stream.runHead, + ); + + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(subscriptionCount)) >= 2) break; + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(loaderCalls)).toBe(2); + expect(yield* Ref.get(subscriptionCount)).toBe(2); }), ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index faa70bc4f3a..6ccb11797f5 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -9,6 +9,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -16,9 +17,10 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import * as ConnectionWakeups from "../connection/wakeups.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; -import { subscribe } from "../rpc/client.ts"; +import { subscribeDynamic } from "../rpc/client.ts"; import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; @@ -50,6 +52,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; const snapshotLoader = yield* ShellSnapshotLoader; + const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; const cachedSnapshot = yield* cache.loadShell(environmentId).pipe( Effect.catch((error) => @@ -67,6 +70,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") status: shellStatusForSnapshot(cachedSnapshot), error: Option.none(), }); + const awaitingCompletion = yield* Ref.make(false); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentShellState.persist")(function* ( @@ -90,10 +94,14 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") Effect.forkScoped, ); - const setDisconnected = SubscriptionRef.update(state, (current) => ({ - ...current, - status: shellStatusForSnapshot(current.snapshot), - })); + const setDisconnected = Ref.set(awaitingCompletion, false).pipe( + Effect.andThen( + SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + })), + ), + ); const setSynchronizing = SubscriptionRef.update(state, (current) => ({ ...current, status: "synchronizing" as const, @@ -109,7 +117,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") }, ); const setStreamError = (error: unknown) => - Effect.logWarning("Could not synchronize the environment shell.").pipe( + Ref.set(awaitingCompletion, false).pipe( + Effect.andThen(Effect.logWarning("Could not synchronize the environment shell.")), Effect.annotateLogs({ environmentId, ...safeErrorLogAttributes(error), @@ -126,6 +135,16 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( item: OrchestrationShellStreamItem, ) { + if (item.kind === "synchronized") { + yield* Ref.set(awaitingCompletion, false); + yield* SubscriptionRef.update(state, (current) => + Option.isSome(current.snapshot) + ? { ...current, status: "live" as const, error: Option.none() } + : current, + ); + return; + } + const current = yield* SubscriptionRef.get(state); const nextSnapshot = item.kind === "snapshot" @@ -141,52 +160,64 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") return; } + const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.set(state, { snapshot: Option.some(nextSnapshot), - status: "live", + status: waiting ? "synchronizing" : "live", error: Option.none(), }); yield* Queue.offer(persistence, nextSnapshot); }); - yield* Effect.forkScoped( - Effect.gen(function* () { - // Establish the base shell snapshot to resume from, minimizing bytes over - // the wire: - // - Warm cache: reuse the cached snapshot (zero network) and resume via - // `afterSequence` so we only receive shell events since the cached - // sequence. - // - Cold cache: load the full shell snapshot over HTTP (gzip-compressible, - // and off the socket), then resume via `afterSequence`. - // If no base can be established we fall back to the socket-embedded - // snapshot so the shell still synchronizes. Overlapping/replayed events are - // deduped by sequence in applyItem. - const base = Option.isSome(cachedSnapshot) - ? cachedSnapshot - : yield* Effect.gen(function* () { - const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe( - Stream.filter(Option.isSome), - Stream.map((current) => current.value), - Stream.runHead, - ); - return Option.isSome(prepared) - ? yield* snapshotLoader.load(prepared.value) - : Option.none(); - }); + const foregroundResubscriptions = Option.match(wakeups, { + onNone: () => Stream.never, + onSome: (service) => + service.changes.pipe(Stream.filter((reason) => reason === "application-active")), + }); - if (Option.isSome(base)) { - yield* applyItem({ kind: "snapshot", snapshot: base.value }); - } + yield* setSynchronizing; + yield* Effect.forkScoped( + subscribeDynamic( + ORCHESTRATION_WS_METHODS.subscribeShell, + Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) { + const supportsCompletionMarker = yield* session.initialConfig.pipe( + Effect.map((config) => config.shellResumeCompletionMarker === true), + Effect.orElseSucceed(() => false), + ); + yield* Ref.set(awaitingCompletion, supportsCompletionMarker); + yield* setSynchronizing; - const subscribeInput = Option.match(base, { - onNone: () => ({}), - onSome: (snapshot) => ({ afterSequence: snapshot.snapshotSequence }), - }); + const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + }), + ), + ); + const httpSnapshot = yield* snapshotLoader.load(prepared); + if (Option.isSome(httpSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + return { + afterSequence: httpSnapshot.value.snapshotSequence, + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; + } - yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, subscribeInput, { + return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + }), + { onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), - }).pipe(Stream.runForEach(applyItem)); - }), + retryExpectedFailureAfter: "250 millis", + resubscribe: foregroundResubscriptions, + }, + ).pipe(Stream.runForEach(applyItem)), ); yield* SubscriptionRef.changes(supervisor.state).pipe( Stream.runForEach((connectionState) => { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 630a5f27d16..f3c4c4e6338 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -26,6 +26,7 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "../connection/model.ts"; +import * as ConnectionWakeups from "../connection/wakeups.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "../rpc/session.ts"; @@ -98,10 +99,17 @@ const ACTIVE_THREAD: OrchestrationThread = { type TestThreadInput = OrchestrationThreadStreamItem | Error; -function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { +function testSession( + client: WsRpcProtocolClient, + options?: { readonly completionMarker?: boolean }, +): RpcSession.RpcSession { return { client, - initialConfig: Effect.never, + initialConfig: Effect.succeed( + options?.completionMarker === true + ? ({ threadResumeCompletionMarker: true } as never) + : ({} as never), + ), ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -122,6 +130,7 @@ function awaitThreadState( const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { readonly cached?: OrchestrationThread; readonly httpSnapshot?: Option.Option; + readonly completionMarker?: boolean; }) { const inputs = yield* Queue.unbounded(); const observed = yield* Queue.unbounded(); @@ -130,8 +139,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const subscriptionCount = yield* Ref.make(0); const loaderCalls = yield* Ref.make(0); const lastSubscribeAfterSequence = yield* Ref.make(undefined); + const lastRequestCompletionMarker = yield* Ref.make(undefined); const savedThreads = yield* Ref.make>([]); const removedThreads = yield* Ref.make>([]); + const wakeups = yield* Queue.unbounded(); const supervisorState = yield* SubscriptionRef.make( AVAILABLE_CONNECTION_STATE, ); @@ -142,16 +153,25 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ), ); const client = { - [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) => + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }) => Stream.unwrap( Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( Effect.andThen(Ref.set(lastSubscribeAfterSequence, input.afterSequence)), + Effect.andThen(Ref.set(lastRequestCompletionMarker, input.requestCompletionMarker)), Effect.as(streamFrom(inputs)), ), ), } as unknown as WsRpcProtocolClient; const supervisorSession = yield* SubscriptionRef.make>( - Option.some(testSession(client)), + Option.some( + testSession( + client, + options?.completionMarker === true ? { completionMarker: true } : undefined, + ), + ), ); const prepared = yield* SubscriptionRef.make>( Option.some(PREPARED), @@ -201,6 +221,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService(ThreadSnapshotLoader, snapshotLoader), + Effect.provideService( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }), + ), ); yield* SubscriptionRef.changes(threadState).pipe( Stream.runForEach((state) => @@ -217,11 +241,21 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o subscriptionCount, loaderCalls, lastSubscribeAfterSequence, + lastRequestCompletionMarker, supervisorState, supervisorSession, savedThreads, removedThreads, - replaceSession: SubscriptionRef.set(supervisorSession, Option.some(testSession(client))), + wakeups, + replaceSession: SubscriptionRef.set( + supervisorSession, + Option.some( + testSession( + client, + options?.completionMarker === true ? { completionMarker: true } : undefined, + ), + ), + ), }; }); @@ -233,6 +267,8 @@ const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem => }, }); +const synchronized = (): OrchestrationThreadStreamItem => ({ kind: "synchronized" }); + const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamItem => ({ kind: "event", event: { @@ -415,6 +451,35 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("does not resurrect a deleted thread when the app returns to the foreground", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + cached: BASE_THREAD, + completionMarker: true, + httpSnapshot: Option.some({ + snapshotSequence: 4, + thread: { ...BASE_THREAD, title: "Stale HTTP thread" }, + }), + }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, deleted()); + yield* awaitThreadState(harness.observed, (value) => value.status === "deleted"); + + expect(yield* Ref.get(harness.loaderCalls)).toBe(0); + yield* Queue.offer(harness.wakeups, "application-active"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break; + yield* Effect.yieldNow; + } + + const latest = yield* Ref.get(harness.latest); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); + expect(yield* Ref.get(harness.loaderCalls)).toBe(0); + expect(latest.status).toBe("deleted"); + expect(Option.isNone(latest.data)).toBe(true); + }), + ); + it.effect("preserves data after a domain failure and resumes on a replacement session", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); @@ -529,4 +594,104 @@ describe("EnvironmentThreads", () => { expect((yield* Ref.get(harness.latest)).status).toBe("live"); }), ); + + it.effect("keeps replayed updates synchronizing until the completion marker arrives", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD, completionMarker: true }); + yield* awaitThreadState( + harness.observed, + (value) => value.status === "synchronizing" && Option.isSome(value.data), + ); + expect(yield* Ref.get(harness.lastRequestCompletionMarker)).toBe(true); + + yield* Queue.offer( + harness.inputs, + titleUpdated("Caught-up title", CACHED_SNAPSHOT_SEQUENCE + 1), + ); + const catchingUp = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "synchronizing" && + Option.isSome(value.data) && + value.data.value.title === "Caught-up title", + ); + expect(catchingUp.status).toBe("synchronizing"); + + yield* Queue.offer(harness.inputs, synchronized()); + const live = yield* awaitThreadState( + harness.observed, + (value) => value.status === "live" && Option.isSome(value.data), + ); + expect(Option.getOrThrow(live.data).title).toBe("Caught-up title"); + }), + ); + + it.effect("resumes replacement sessions from the latest applied sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD, completionMarker: true }); + yield* Queue.offer( + harness.inputs, + titleUpdated("Latest title", CACHED_SNAPSHOT_SEQUENCE + 1), + ); + yield* Queue.offer(harness.inputs, synchronized()); + yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Latest title", + ); + + yield* harness.replaceSession; + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break; + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); + expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); + expect((yield* Ref.get(harness.latest)).status).toBe("synchronizing"); + }), + ); + + it.effect("resubscribes on app foreground from the latest applied sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD, completionMarker: true }); + yield* Queue.offer( + harness.inputs, + titleUpdated("Latest title", CACHED_SNAPSHOT_SEQUENCE + 1), + ); + yield* Queue.offer(harness.inputs, synchronized()); + yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Latest title", + ); + + yield* Queue.offer(harness.wakeups, "application-active"); + const synchronizing = yield* awaitThreadState( + harness.observed, + (value) => value.status === "synchronizing" && Option.isSome(value.data), + ); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break; + yield* Effect.yieldNow; + } + + expect(synchronizing.status).toBe("synchronizing"); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); + expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); + expect(yield* Ref.get(harness.lastRequestCompletionMarker)).toBe(true); + expect(yield* Ref.get(harness.loaderCalls)).toBe(0); + + yield* Queue.offer(harness.inputs, synchronized()); + const live = yield* awaitThreadState( + harness.observed, + (value) => value.status === "live" && Option.isSome(value.data), + ); + expect(Option.getOrThrow(live.data).title).toBe("Latest title"); + }), + ); }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 0c9ec340610..196229cc8b1 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -10,6 +10,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; @@ -17,8 +18,9 @@ import { Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; -import { subscribe } from "../rpc/client.ts"; +import { subscribeDynamic } from "../rpc/client.ts"; import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; @@ -52,6 +54,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; const snapshotLoader = yield* ThreadSnapshotLoader; + const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; const cached = yield* cache.loadThread(environmentId, threadId).pipe( Effect.catch((error) => @@ -76,6 +79,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const lastSequence = yield* SubscriptionRef.make( Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); + const awaitingCompletion = yield* Ref.make(false); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( @@ -100,11 +104,15 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); - const setSynchronizing = SubscriptionRef.update(state, (current) => ({ - ...current, - status: "synchronizing" as const, - error: Option.none(), - })); + const setSynchronizing = SubscriptionRef.update(state, (current) => + current.status === "deleted" + ? current + : { + ...current, + status: "synchronizing" as const, + error: Option.none(), + }, + ); const setReady = SubscriptionRef.update(state, (current) => current.status === "live" || current.status === "deleted" ? current @@ -114,23 +122,32 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make error: Option.none(), }, ); - const setDisconnected = SubscriptionRef.update(state, (current) => ({ - ...current, - status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), - })); - const setStreamError = (cause: Cause.Cause) => - SubscriptionRef.update(state, (current) => ({ + const setDisconnected = Effect.gen(function* () { + yield* Ref.set(awaitingCompletion, false); + yield* SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), - error: Option.some(formatThreadError(cause)), })); + }); + const setStreamError = (cause: Cause.Cause) => + Ref.set(awaitingCompletion, false).pipe( + Effect.andThen( + SubscriptionRef.update(state, (current) => ({ + ...current, + status: + current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), + error: Option.some(formatThreadError(cause)), + })), + ), + ); const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( thread: OrchestrationThread, ) { + const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.set(state, { data: Option.some(thread), - status: "live", + status: waiting ? "synchronizing" : "live", error: Option.none(), }); // Active threads can update many times per second and retain large tool @@ -143,6 +160,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }); const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { + yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.set(state, { data: Option.none(), status: "deleted", @@ -164,6 +182,16 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( item: OrchestrationThreadStreamItem, ) { + if (item.kind === "synchronized") { + yield* Ref.set(awaitingCompletion, false); + yield* SubscriptionRef.update(state, (current) => + Option.isSome(current.data) && current.status !== "deleted" + ? { ...current, status: "live" as const, error: Option.none() } + : current, + ); + return; + } + if (item.kind === "snapshot") { yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread); @@ -205,48 +233,69 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); + const foregroundResubscriptions = Option.match(wakeups, { + onNone: () => Stream.never, + onSome: (service) => + service.changes.pipe(Stream.filter((reason) => reason === "application-active")), + }); + yield* setSynchronizing; yield* Effect.forkScoped( - Effect.gen(function* () { - // Establish the base snapshot to resume from, minimizing bytes over the - // wire: - // - Warm cache: reuse the cached snapshot (zero network) and resume via - // `afterSequence` so we only receive events since the cached sequence. - // - Cold cache: load the full snapshot over HTTP (gzip-compressible, and - // off the socket), then resume via `afterSequence`. - // If no base can be established we fall back to the socket-embedded - // snapshot so the thread still synchronizes. Overlapping/replayed events - // are deduped by sequence in applyItem. - const base = Option.isSome(cached) - ? cached - : yield* Effect.gen(function* () { - // Cold cache only: wait for a prepared connection so we can - // authenticate the HTTP request; this mirrors the socket path, which - // likewise waits for a live session. - const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe( - Stream.filter(Option.isSome), - Stream.map((current) => current.value), - Stream.runHead, - ); - return Option.isSome(prepared) - ? yield* snapshotLoader.load(prepared.value, threadId) - : Option.none(); - }); + subscribeDynamic( + ORCHESTRATION_WS_METHODS.subscribeThread, + Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) { + const supportsCompletionMarker = yield* session.initialConfig.pipe( + Effect.map((config) => config.threadResumeCompletionMarker === true), + Effect.orElseSucceed(() => false), + ); + yield* Ref.set(awaitingCompletion, supportsCompletionMarker); + yield* setSynchronizing; - if (Option.isSome(base)) { - yield* applyItem({ kind: "snapshot", snapshot: base.value }); - } + let current = yield* SubscriptionRef.get(state); + if (Option.isNone(current.data) && current.status !== "deleted") { + const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + }), + ), + ); + const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); + if (Option.isSome(httpSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + current = yield* SubscriptionRef.get(state); + } + } - const subscribeInput = Option.match(base, { - onNone: () => ({ threadId }), - onSome: (snapshot) => ({ threadId, afterSequence: snapshot.snapshotSequence }), - }); + const sequence = yield* SubscriptionRef.get(lastSequence); + const canResume = Option.isSome(current.data); + if (!supportsCompletionMarker && canResume) { + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + status: value.status === "deleted" ? value.status : ("live" as const), + error: Option.none(), + })); + } - yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeThread, subscribeInput, { + return { + threadId, + ...(canResume ? { afterSequence: sequence } : {}), + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; + }), + { onExpectedFailure: setStreamError, retryExpectedFailureAfter: "250 millis", - }).pipe(Stream.runForEach(applyItem)); - }), + resubscribe: foregroundResubscriptions, + }, + ).pipe(Stream.runForEach(applyItem)), ); yield* Effect.addFinalizer(() => diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7e9c421b5df..c0c9c62d080 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -443,6 +443,9 @@ export const OrchestrationShellStreamEvent = Schema.Union([ export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type; export const OrchestrationShellStreamItem = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("synchronized"), + }), Schema.Struct({ kind: Schema.Literal("snapshot"), snapshot: OrchestrationShellSnapshot, @@ -461,6 +464,11 @@ export const OrchestrationSubscribeShellInput = Schema.Struct({ * client). */ afterSequence: Schema.optionalKey(NonNegativeInt), + /** + * Requests an explicit marker after the subscription has emitted its initial + * snapshot or catch-up replay and before it begins emitting live events. + */ + requestCompletionMarker: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShellInput.Type; @@ -474,6 +482,11 @@ export const OrchestrationSubscribeThreadInput = Schema.Struct({ * sequence on the client). */ afterSequence: Schema.optionalKey(NonNegativeInt), + /** + * Requests an explicit marker after the subscription has emitted its initial + * snapshot or catch-up replay and before it begins emitting live events. + */ + requestCompletionMarker: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; @@ -1135,6 +1148,9 @@ export const OrchestrationEvent = Schema.Union([ export type OrchestrationEvent = typeof OrchestrationEvent.Type; export const OrchestrationThreadStreamItem = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("synchronized"), + }), Schema.Struct({ kind: Schema.Literal("snapshot"), snapshot: OrchestrationThreadDetailSnapshot, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..316f09693ec 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -417,6 +417,10 @@ export const ServerConfig = Schema.Struct({ availableEditors: Schema.Array(EditorId), observability: ServerObservability, settings: ServerSettings, + /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ + shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ + threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), }); export type ServerConfig = typeof ServerConfig.Type; From b6d9ce325c4586ea3ab2d2ea5cfd5e8f8c167aeb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 12:17:30 +0200 Subject: [PATCH 018/110] Gate iOS glass layout on native support (#4032) Co-authored-by: codex --- apps/mobile/src/Stack.tsx | 18 ++++++++++++------ .../src/features/files/FileTreeBrowser.tsx | 18 +++++++----------- apps/mobile/src/features/home/HomeScreen.tsx | 7 ++++--- .../features/threads/ThreadDetailScreen.tsx | 9 ++++++++- .../threads/ThreadNavigationSidebar.tsx | 7 +++++-- .../src/features/threads/ThreadRouteScreen.tsx | 3 ++- .../threads/sidebar-navigation-shell.tsx | 9 +++++---- .../src/lib/native-glass-capability.test.ts | 18 ++++++++++++++++++ apps/mobile/src/lib/native-glass-capability.ts | 6 ++++++ apps/mobile/src/native/native-glass.ts | 9 +++++++++ 10 files changed, 76 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/lib/native-glass-capability.test.ts create mode 100644 apps/mobile/src/lib/native-glass-capability.ts create mode 100644 apps/mobile/src/native/native-glass.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4eb4a5061e8..4cf787d9ce5 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -59,6 +59,7 @@ import { EMPTY_INCOMING_SHARE_PRESENTATION_STATE, transitionIncomingSharePresentation, } from "./features/sharing/incoming-share-presentation"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; @@ -78,19 +79,24 @@ type AppScreenOptions = NativeStackNavigationOptions & { // Shared header presets. Screens only override genuinely dynamic values (titles, // subtitles, toolbar items, search callbacks) via NativeStackScreenOptions. // -// GLASS: transparent header over the screen's primary scroll view, with the iOS 26 -// scroll-edge blur sampling the content (Home, Thread, Files tree, settings sheet). +// GLASS: transparent header over the screen's primary scroll view on supported +// iOS versions. Pre-glass iOS gets the same solid material as internal-scroll +// surfaces so content is laid out below the bar instead of underlapping it. const GLASS_HEADER_OPTIONS: AppScreenOptions = { headerBackButtonDisplayMode: "minimal", headerBackTitle: "", headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined, + headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED + ? { backgroundColor: "transparent" } + : SHEET_BACKGROUND_COLOR !== undefined + ? { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string } + : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, - headerTransparent: Platform.OS === "ios", - scrollEdgeEffects: Platform.OS === "ios" ? HEADER_SCROLL_EDGE_EFFECTS : undefined, - unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, + headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, + scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined, + unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; // SOLID: opaque sheet-colored header for surfaces whose content scrolls internally diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index b29121201f5..dd7a1271194 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -1,20 +1,14 @@ import type { ProjectEntry } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ActivityIndicator, - FlatList, - Platform, - Pressable, - RefreshControl, - View, -} from "react-native"; +import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { buildFileTree, defaultExpandedTreePaths, @@ -129,7 +123,7 @@ export function FileTreeBrowser(props: { const insets = useSafeAreaInsets(); // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. - const headerInset = Platform.OS === "ios" ? insets.top + 44 : 0; + const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 44 : 0; const iconColor = String(useThemeColor("--color-icon-muted")); const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); @@ -249,9 +243,11 @@ export function FileTreeBrowser(props: { className="flex-1" data={visibleNodes} keyExtractor={(item) => item.node.path} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} scrollIndicatorInsets={ - Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined + NATIVE_LIQUID_GLASS_SUPPORTED + ? { top: headerInset, left: 0, right: 0, bottom: 0 } + : undefined } keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index c463e4a1bb3..dcc56fd77c5 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -24,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -388,7 +389,7 @@ export function HomeScreen(props: HomeScreenProps) { className="flex-1 items-center justify-center bg-screen px-8" style={{ paddingBottom: Math.max(insets.bottom, 24), - paddingTop: Platform.OS === "ios" ? insets.top + 72 : 0, + paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0, }} > @@ -469,8 +470,8 @@ export function HomeScreen(props: HomeScreenProps) { ListHeaderComponent={listHeader} ListEmptyComponent={listEmpty} style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} + contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 31ad5660983..32527b0b719 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -28,6 +28,7 @@ import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import type { PendingApproval, PendingUserInput, @@ -197,7 +198,13 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { entering={FadeInDown.duration(200)} exiting={FadeOut.duration(140)} > - + diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 6e0e243aad6..f7ee4eaa74f 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -17,6 +17,7 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -571,8 +572,10 @@ function ThreadNavigationSidebarPane( itemsAreEqual={homeListItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} - automaticallyAdjustsScrollIndicatorInsets - contentInsetAdjustmentBehavior="automatic" + automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} + contentInsetAdjustmentBehavior={ + NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never" + } contentContainerStyle={[ styles.threadListContent, { diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index e60f03bb8c3..7fb4740ddce 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -23,6 +23,7 @@ import { } from "../../components/AndroidScreenHeader"; import { LoadingScreen } from "../../components/LoadingScreen"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { connectionTone } from "../connection/connectionTone"; import { @@ -278,7 +279,7 @@ function ThreadRouteContent( ); /* ─── Native header theming ──────────────────────────────────────── */ - const usesNativeHeaderGlass = Platform.OS === "ios"; + const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED; const headerSubtitle = [ selectedThreadProject?.title ?? null, selectedEnvironmentConnection?.environmentLabel ?? null, diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index f1e06926d0b..f0e3f89c07d 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -11,6 +11,7 @@ import { import type { ReactNode } from "react"; import { Platform, useColorScheme } from "react-native"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -33,12 +34,12 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { headerLargeTitle: false, headerShadowVisible: false, headerShown: true, - headerStyle: { backgroundColor: "transparent" }, + headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, - headerTransparent: true, - scrollEdgeEffects: SCROLL_EDGE_EFFECTS, + headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, + scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined, title: "Threads", - unstable_navigationItemStyle: "editor", + unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; const SidebarStack = createNativeStackNavigator(); diff --git a/apps/mobile/src/lib/native-glass-capability.test.ts b/apps/mobile/src/lib/native-glass-capability.test.ts new file mode 100644 index 00000000000..43f865c77c3 --- /dev/null +++ b/apps/mobile/src/lib/native-glass-capability.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { supportsNativeLiquidGlass } from "./native-glass-capability"; + +describe("supportsNativeLiquidGlass", () => { + it("uses native liquid glass when iOS reports the capability", () => { + expect(supportsNativeLiquidGlass("ios", true)).toBe(true); + }); + + it("keeps pre-glass iOS on the solid fallback", () => { + expect(supportsNativeLiquidGlass("ios", false)).toBe(false); + }); + + it("does not enable iOS liquid-glass layout behavior on other platforms", () => { + expect(supportsNativeLiquidGlass("android", true)).toBe(false); + expect(supportsNativeLiquidGlass("web", true)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/native-glass-capability.ts b/apps/mobile/src/lib/native-glass-capability.ts new file mode 100644 index 00000000000..61c8547b507 --- /dev/null +++ b/apps/mobile/src/lib/native-glass-capability.ts @@ -0,0 +1,6 @@ +export function supportsNativeLiquidGlass( + platform: string, + nativeCapabilityAvailable: boolean, +): boolean { + return platform === "ios" && nativeCapabilityAvailable; +} diff --git a/apps/mobile/src/native/native-glass.ts b/apps/mobile/src/native/native-glass.ts new file mode 100644 index 00000000000..40b28076d36 --- /dev/null +++ b/apps/mobile/src/native/native-glass.ts @@ -0,0 +1,9 @@ +import { isLiquidGlassSupported } from "@callstack/liquid-glass"; +import { Platform } from "react-native"; + +import { supportsNativeLiquidGlass } from "../lib/native-glass-capability"; + +export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass( + Platform.OS, + isLiquidGlassSupported, +); From f4da4f3b4037260bbb0d8914acbebafd2206607a Mon Sep 17 00:00:00 2001 From: Vadym Kotai Date: Mon, 20 Jul 2026 14:20:28 +0400 Subject: [PATCH 019/110] fix(opencode): resume the OpenCode session on follow-ups instead of starting an empty one (#3617) Co-authored-by: codex --- .../provider/Layers/OpenCodeAdapter.test.ts | 382 +++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 242 ++++++++++- 2 files changed, 601 insertions(+), 23 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 4358cf305b0..9e7211dc3e8 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -5,8 +5,10 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -31,6 +33,8 @@ import { } from "../opencodeRuntime.ts"; import { appendOpenCodeAssistantTextDelta, + isOpenCodeNotFound, + isSameOpenCodeDirectory, makeOpenCodeAdapter, mergeOpenCodeAssistantText, } from "./OpenCodeAdapter.ts"; @@ -64,6 +68,12 @@ const runtimeMock = { closeError: null as Error | null, messages: [] as MessageEntry[], subscribedEvents: [] as unknown[], + sessionGetIds: [] as string[], + missingSessionIds: new Set(), + transientErrorSessionIds: new Set(), + sessionDirectoryById: new Map(), + sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, + forkCalls: [] as Array<{ sessionID: string; directory?: string }>, }, reset() { this.state.startCalls.length = 0; @@ -78,6 +88,12 @@ const runtimeMock = { this.state.closeError = null; this.state.messages = []; this.state.subscribedEvents = []; + this.state.sessionGetIds.length = 0; + this.state.missingSessionIds.clear(); + this.state.transientErrorSessionIds.clear(); + this.state.sessionDirectoryById.clear(); + this.state.sessionUpdateCalls.length = 0; + this.state.forkCalls.length = 0; }, }; @@ -102,10 +118,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { connectToOpenCodeServer: ({ serverUrl }) => Effect.gen(function* () { const url = serverUrl ?? "http://127.0.0.1:4301"; - // Unconditionally register a scope finalizer for test observability — - // preserves the `closeCalls` / `closeError` probes that the existing - // suites rely on. Production code never attaches a finalizer to an - // external server (it simply returns `Effect.succeed(...)`). + // Always register a finalizer so the closeCalls/closeError probes fire; + // production attaches none for external servers. yield* Effect.addFinalizer(() => Effect.sync(() => { runtimeMock.state.closeCalls.push(url); @@ -132,6 +146,34 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ); return { data: { id: `${baseUrl}/session` } }; }, + get: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.sessionGetIds.push(sessionID); + // The real client is `throwOnError: true`: non-2xx rejects rather + // than resolving, so missing → 404 throw, transient → 500 throw. + if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { + throw new Error("opencode server error", { cause: { status: 500 } }); + } + if (runtimeMock.state.missingSessionIds.has(sessionID)) { + throw new Error(`Session not found: ${sessionID}`, { + cause: { status: 404, body: { name: "NotFoundError" } }, + }); + } + const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); + return { data: { id: sessionID, ...(directory ? { directory } : {}) } }; + }, + update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { + runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); + return { data: { id: sessionID } }; + }, + fork: async ({ sessionID, directory }: { sessionID: string; directory?: string }) => { + // Fork clones history into a new session bound to the directory. + const forkedId = `${sessionID}_fork`; + runtimeMock.state.forkCalls.push({ sessionID, ...(directory ? { directory } : {}) }); + if (directory) { + runtimeMock.state.sessionDirectoryById.set(forkedId, directory); + } + return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; + }, abort: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.abortCalls.push(sessionID); }, @@ -251,6 +293,256 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("returns a durable resume cursor for a freshly created session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-cursor"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + // Without a persisted cursor, a session is created and its id is + // surfaced as a resume cursor so the upper layer can persist it. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("resumes the persisted OpenCode session instead of creating a new one", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-resume"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, + }); + + // The adapter validates the persisted id with session.get and re-adopts + // it — no new session is minted (issue #3604). + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_persisted"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_persisted", + }); + // Resume re-asserts the permission ruleset for the current runtimeMode. + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_persisted"); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("sends follow-up turns to the resumed session id", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-resume-turn"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, + }); + + const result = yield* adapter.sendTurn({ + threadId, + input: "continue where we left off", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/sonnet", + ), + }); + + // The prompt targets the resumed id, and the turn re-surfaces the cursor. + NodeAssert.deepEqual( + (runtimeMock.state.promptCalls[0] as { sessionID: string }).sessionID, + "ses_persisted", + ); + NodeAssert.deepEqual(result.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_persisted", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("falls back to a fresh session when the persisted session is gone", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stale"); + runtimeMock.state.missingSessionIds.add("ses_stale"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_stale" }, + }); + + // get probed the stale id, found nothing, then created a new session and + // emitted a fresh cursor rather than wedging the thread. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_stale"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores a malformed or wrong-version resume cursor", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-badcursor"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 99, sessionId: "ses_persisted" }, + }); + + // A foreign/stale-shaped cursor is treated as "no resume": never probed, + // a fresh session is created. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "http://127.0.0.1:9999/session", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces a non-not-found resume probe error instead of silently starting fresh", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-transient"); + // session.get returns a 500 (not a 404) for this id. + runtimeMock.state.transientErrorSessionIds.add("ses_transient"); + + const exit = yield* Effect.exit( + adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_transient" }, + }), + ); + + // A transient/transport/auth failure must propagate — NOT be masked as a + // brand-new empty session (the #3604 class of silent context loss). + NodeAssert.equal(Exit.isFailure(exit), true); + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_transient"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + }), + ); + + it.effect("re-applies the current runtimeMode permissions when resuming", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-perms"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + // A different runtimeMode than the original create — resume must not + // leave the upstream session on stale permissions. + runtimeMode: "approval-required", + threadId, + resumeCursor: { schemaVersion: 1, sessionId: "ses_perms" }, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_perms"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_perms"); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "forks the resumed session into the requested directory instead of losing context", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-cwd"); + // The persisted session still exists but was created in another working dir + // (e.g. the thread moved from the project root into a git worktree). + runtimeMock.state.sessionDirectoryById.set("ses_otherdir", "/some/other/worktree"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_otherdir" }, + }); + + // A cwd change must not mint an empty session: the adapter forks the + // persisted session into the requested cwd, carrying history forward. + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_otherdir"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.equal(runtimeMock.state.forkCalls.length, 1); + NodeAssert.equal(runtimeMock.state.forkCalls[0]?.sessionID, "ses_otherdir"); + NodeAssert.equal(typeof runtimeMock.state.forkCalls[0]?.directory, "string"); + // Permission ruleset re-asserted on the fork for the current runtimeMode. + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_otherdir_fork"); + // Durable cursor now points at the history-complete fork in the new directory. + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_otherdir_fork", + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reuses the resumed session when the stored directory differs only lexically", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-samedir"); + // Same working tree, different spelling (trailing slash) — must reuse, + // not fork. + runtimeMock.state.sessionDirectoryById.set("ses_samedir", `${process.cwd()}/`); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_samedir" }, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_samedir"]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_samedir", + }); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("fails sendTurn for missing sessions through the typed error channel", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -673,6 +965,88 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("classifies a confirmed not-found across the shapes the SDK/runtime can produce", () => + Effect.sync(() => { + // The real production shape: runOpenCodeSdk wraps the thrown Error + // (cause = { body, status }) under OpenCodeRuntimeError. + const wrappedError = new Error("Session not found: ses_x", { + cause: { body: { name: "NotFoundError" }, status: 404 }, + }); + NodeAssert.equal( + isOpenCodeNotFound({ + _tag: "OpenCodeRuntimeError", + operation: "session.get", + detail: "Session not found: ses_x", + cause: wrappedError, + }), + true, + ); + + // 404 expressed only via response.status (the bot's flagged shape). + NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 404 } } }), true); + // 404 via a bare numeric status / statusCode. + NodeAssert.equal(isOpenCodeNotFound(new Error("x", { cause: { status: 404 } })), true); + NodeAssert.equal(isOpenCodeNotFound({ statusCode: 404 }), true); + // OpenCode NotFoundError body name with no status. + NodeAssert.equal(isOpenCodeNotFound({ body: { name: "NotFoundError" } }), true); + + // NOT a miss: only structured signals count, never free text. A non-404 + // error whose message/detail merely contains "not found" must propagate, + // not be misread as a missing session and silently start fresh. + NodeAssert.equal( + isOpenCodeNotFound(new Error("upstream provider not found", { cause: { status: 500 } })), + false, + ); + NodeAssert.equal(isOpenCodeNotFound({ detail: "status=500 body={...not found...}" }), false); + // An explicit non-404 status seals its subtree: a 500 whose serialized + // body echoes a NotFoundError name — or that is itself named + // *NotFound* — is a real failure, never a miss. + NodeAssert.equal(isOpenCodeNotFound({ status: 500, body: { name: "NotFoundError" } }), false); + NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError", status: 500 }), false); + // A "NotFound"-flavored name that isn't OpenCode's exact `NotFoundError` + // is not a confirmed miss even without a sealing status. + NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError" }), false); + NodeAssert.equal(isOpenCodeNotFound({ cause: { name: "ProviderNotFoundError" } }), false); + NodeAssert.equal( + isOpenCodeNotFound( + new Error("x", { cause: { status: 502, body: { name: "NotFoundError" } } }), + ), + false, + ); + // Other transient/auth/network failures must propagate too. + NodeAssert.equal(isOpenCodeNotFound(new Error("boom", { cause: { status: 500 } })), false); + NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 401 } } }), false); + NodeAssert.equal(isOpenCodeNotFound(new Error("network error (no response)")), false); + NodeAssert.equal(isOpenCodeNotFound(undefined), false); + }), + ); + + it.effect("treats lexically or physically identical directories as the same", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sameDirectory = (left: string, right: string) => + isSameOpenCodeDirectory(fileSystem, path, left, right); + + // Lexical-only differences (trailing slash, dot segments) short-circuit + // without touching the filesystem — the paths need not exist. + NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true); + NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true); + // Nonexistent paths degrade to the lexical comparison instead of failing. + NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false); + + // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to + // the directory it points at, so the two spellings compare equal. + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" }); + const real = path.join(base, "real"); + const link = path.join(base, "link"); + yield* fileSystem.makeDirectory(real); + yield* fileSystem.symlink(real, link); + NodeAssert.equal(yield* sameDirectory(link, real), true); + NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false); + }).pipe(Effect.scoped), + ); + it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => Effect.sync(() => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index e7622e6c705..73c23b77e68 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -17,6 +17,8 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; @@ -53,6 +55,114 @@ import * as Option from "effect/Option"; const PROVIDER = ProviderDriverKind.make("opencode"); +/** + * Version tag stamped into the OpenCode resume cursor. Bump if the cursor + * shape changes so stale-shaped cursors written by older builds are ignored + * rather than misread (mirrors GROK_RESUME_VERSION / CURSOR_RESUME_VERSION). + */ +const OPENCODE_RESUME_VERSION = 1 as const; + +/** + * Decode a persisted resume cursor into the upstream `ses_…` id. Anything + * that isn't a current-version cursor with a non-empty id means "no resume" + * rather than an error. Re-adopting the session id IS the resume mechanism — + * OpenCode scopes a conversation's history by session id. + */ +function parseOpenCodeResume(raw: unknown): { readonly sessionId: string } | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + if (record.schemaVersion !== OPENCODE_RESUME_VERSION) { + return undefined; + } + if (typeof record.sessionId !== "string" || record.sessionId.trim().length === 0) { + return undefined; + } + return { sessionId: record.sessionId.trim() }; +} + +/** + * Whether an error definitively reports a missing session. Only a confirmed + * miss may silently start a fresh session; any other failure (the SDK client + * is `throwOnError: true`, so `session.get` rejects on every non-2xx) must + * propagate, or a transient blip resets a live thread to an empty one — the + * #3604 silent context loss. Decides on structured signals only, never free + * text: a numeric 404 or the exact `NotFoundError` name, found via a bounded walk + * over `cause`/`body`/`error`/`data`. An explicit non-404 status seals its + * subtree so a wrapped "NotFound" name can't reclassify a real failure. + * Exported for unit testing. + */ +export function isOpenCodeNotFound(cause: unknown): boolean { + const seen = new Set(); + const queue: Array = [cause]; + for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { + const node = queue.shift(); + if (node === null || typeof node !== "object" || seen.has(node)) { + continue; + } + seen.add(node); + const record = node as Record; + + const response = record.response; + const statuses = [ + record.status, + record.statusCode, + response !== null && typeof response === "object" + ? (response as { readonly status?: unknown }).status + : undefined, + ].filter((status): status is number => typeof status === "number"); + if (statuses.includes(404)) { + return true; + } + if (statuses.length > 0) { + continue; + } + + const name = record.name; + if (typeof name === "string" && name.toLowerCase() === "notfounderror") { + return true; + } + + for (const key of ["cause", "body", "error", "data"] as const) { + if (record[key] !== undefined) { + queue.push(record[key]); + } + } + } + return false; +} + +/** + * Whether two directory spellings name the same location. Raw string + * equality misreads a trailing slash, `.`/`..` segment, or symlinked cwd + * (macOS `/tmp` → `/private/tmp`) as a cwd change, needlessly forking the + * session on every resume. Lexically equal paths short-circuit; otherwise + * both sides go through `realPath`, each falling back to its lexical form + * on failure (deleted directory, external-server path) — so the probe can + * only widen matches, never split them. Takes the services as arguments so + * adapter methods stay service-free. Exported for unit testing. + */ +export function isSameOpenCodeDirectory( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + left: string, + right: string, +): Effect.Effect { + const lexicalLeft = path.resolve(left); + const lexicalRight = path.resolve(right); + if (lexicalLeft === lexicalRight) { + return Effect.succeed(true); + } + const canonicalize = (lexical: string) => + fileSystem.realPath(lexical).pipe(Effect.orElseSucceed(() => lexical)); + return Effect.zipWith( + canonicalize(lexicalLeft), + canonicalize(lexicalRight), + (canonicalLeft, canonicalRight) => canonicalLeft === canonicalRight, + ); +} + interface OpenCodeTurnSnapshot { readonly id: TurnId; readonly items: Array; @@ -459,6 +569,10 @@ export function makeOpenCodeAdapter( const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sameDirectory = (left: string, right: string) => + isSameOpenCodeDirectory(fileSystem, path, left, right); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -1076,6 +1190,7 @@ export function makeOpenCodeAdapter( const serverUrl = openCodeSettings.serverUrl; const serverPassword = openCodeSettings.serverPassword; const directory = input.cwd ?? serverConfig.cwd; + const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; const existing = sessions.get(input.threadId); if (existing) { yield* stopOpenCodeContext(existing); @@ -1115,22 +1230,96 @@ export function makeOpenCodeAdapter( }), ); } - const openCodeSession = yield* runOpenCodeSdk("session.create", () => - client.session.create({ - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); - if (!openCodeSession.data) { - return yield* new OpenCodeRuntimeError({ - operation: "session.create", - detail: "OpenCode session.create returned no session payload.", - }); - } + // Resume: re-adopt the session named by the durable cursor — + // OpenCode scopes history by session id. The probe recovers only + // a confirmed not-found (start fresh); transport/auth/server + // errors propagate instead of masking as a new empty session. + const resolved = yield* Effect.gen(function* () { + const adopted = resumeSessionId + ? yield* runOpenCodeSdk("session.get", () => + client.session.get({ sessionID: resumeSessionId }), + ).pipe( + Effect.map((response) => response.data), + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + ) + : undefined; + + // Reuse in place only when the session still matches the + // requested cwd; on a cwd change it is forked below instead. + const reusable = + adopted && + (!adopted.directory || (yield* sameDirectory(adopted.directory, directory))) + ? adopted + : undefined; + + if (reusable) { + // Resume skips `session.create`, so re-assert the ruleset — + // a runtime-mode change would otherwise leave the session on + // its original permissions. + yield* runOpenCodeSdk("session.update", () => + client.session.update({ + sessionID: reusable.id, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + return { openCodeSession: reusable, created: false }; + } + + // The session lives under a different cwd (e.g. the thread + // moved into a git worktree). Fork it into the requested + // directory instead of minting an empty one — the fork carries + // the full history, so the follow-up keeps its context (#3604). + if (adopted) { + yield* Effect.logInfo( + `OpenCode session '${adopted.id}' was created under a different working directory; forking into '${directory}' to preserve conversation history.`, + ); + const forkedSession = yield* runOpenCodeSdk("session.fork", () => + client.session.fork({ sessionID: adopted.id, directory }), + ); + const forked = forkedSession.data; + if (!forked) { + return yield* new OpenCodeRuntimeError({ + operation: "session.fork", + detail: "OpenCode session.fork returned no session payload.", + }); + } + yield* runOpenCodeSdk("session.update", () => + client.session.update({ + sessionID: forked.id, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + return { openCodeSession: forked, created: true }; + } + + if (resumeSessionId) { + yield* Effect.logWarning( + `OpenCode session '${resumeSessionId}' no longer exists; starting a fresh session.`, + ); + } + const createdSession = yield* runOpenCodeSdk("session.create", () => + client.session.create({ + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + if (!createdSession.data) { + return yield* new OpenCodeRuntimeError({ + operation: "session.create", + detail: "OpenCode session.create returned no session payload.", + }); + } + return { openCodeSession: createdSession.data, created: true }; + }); + return { sessionScope, server, client, - openCodeSession: openCodeSession.data, + openCodeSession: resolved.openCodeSession, + created: resolved.created, }; }).pipe(Effect.provideService(Scope.Scope, sessionScope)), ); @@ -1145,13 +1334,16 @@ export function makeOpenCodeAdapter( // and already inserted a session while we were awaiting async work. const raceWinner = sessions.get(input.threadId); if (raceWinner) { - // Another call won the race – clean up the session we just created - // (including the remote SDK session) and return the existing one. - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSession.id, - }), - ).pipe(Effect.ignore); + // Another call won the race — clean up. Only abort the remote + // session if we created it here; a resumed one is shared upstream + // state the winner is now using. + if (started.created) { + yield* runOpenCodeSdk("session.abort", () => + started.client.session.abort({ + sessionID: started.openCodeSession.id, + }), + ).pipe(Effect.ignore); + } yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); return raceWinner.session; } @@ -1165,6 +1357,13 @@ export function makeOpenCodeAdapter( cwd: directory, ...(input.modelSelection ? { model: input.modelSelection.model } : {}), threadId: input.threadId, + // ProviderService persists this cursor and feeds it back into + // `startSession` after the in-memory session is lost (reaper / + // restart), so follow-ups continue the same conversation (#3604). + resumeCursor: { + schemaVersion: OPENCODE_RESUME_VERSION, + sessionId: started.openCodeSession.id, + }, createdAt, updatedAt: createdAt, }; @@ -1330,6 +1529,11 @@ export function makeOpenCodeAdapter( return { threadId: input.threadId, turnId, + // Re-surface the durable cursor on every turn so the persisted binding + // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex). + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), }; }); From 0ca3240691bf1773802b3ed70330515d68b0a6b8 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:52:00 +0530 Subject: [PATCH 020/110] fix(server): use CLI for OpenCode health check instead of spawning server (#4153) --- .../provider/Layers/OpenCodeAdapter.test.ts | 8 + .../provider/Layers/OpenCodeProvider.test.ts | 22 +- .../src/provider/Layers/OpenCodeProvider.ts | 42 ++-- .../opencodeRuntime.cliParsers.test.ts | 229 ++++++++++++++++++ apps/server/src/provider/opencodeRuntime.ts | 176 ++++++++++++++ .../OpenCodeTextGeneration.test.ts | 8 + 6 files changed, 465 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/provider/opencodeRuntime.cliParsers.test.ts diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9e7211dc3e8..1385ccbaabe 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -221,6 +221,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadInventoryFromCli: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", + cause: null, + }), + ), }; const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b0e785512dc..05160517bfe 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -95,6 +95,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }), ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), + loadInventoryFromCli: () => + runtimeMock.state.inventoryError + ? Effect.succeed({ + providerList: { all: [], default: {}, connected: [] as string[] }, + agents: [], + } as OpenCodeInventory) + : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), }; beforeEach(() => { @@ -197,11 +204,22 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("closes the local OpenCode server scope after provider refresh", () => + it.effect("does not spawn a local server for health check (uses CLI instead)", () => Effect.gen(function* () { yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - NodeAssert.equal(runtimeMock.state.closeCalls, 1); + NodeAssert.equal(runtimeMock.state.closeCalls, 0); + }), + ); + + it.effect("degrades gracefully on CLI failure for local installs", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("opencode models failed"); + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.models.length, 0); }), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index a8285e960fc..2c63350014d 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -409,26 +409,32 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu } const inventoryExit = yield* Effect.exit( - Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ + (isExternalServer + ? Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + serverUrl: openCodeSettings.serverUrl, + environment: resolvedEnvironment, + }); + return yield* openCodeRuntime.loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }), + ); + }), + ) + : openCodeRuntime.loadInventoryFromCli({ binaryPath: openCodeSettings.binaryPath, - serverUrl: openCodeSettings.serverUrl, environment: resolvedEnvironment, - }); - return yield* openCodeRuntime.loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(isExternalServer && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }), - ); - }).pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), + }) + ).pipe( + Effect.mapError( + (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), ), ); diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts new file mode 100644 index 00000000000..6208f04507e --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -0,0 +1,229 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { parseModelsCliOutput, parseAgentListCliOutput } from "./opencodeRuntime.ts"; + +describe("parseModelsCliOutput", () => { + it("parses a single model from a single provider", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + JSON.stringify({ + id: "claude-sonnet-4-5", + providerID: "anthropic", + name: "Claude Sonnet 4.5", + capabilities: { temperature: true, reasoning: true, toolcall: true }, + cost: { input: 3, output: 15 }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.equal(result.connected.length, 1); + NodeAssert.equal(result.connected[0], "anthropic"); + + const provider = result.providers.get("anthropic")!; + NodeAssert.ok(provider); + NodeAssert.equal(provider.id, "anthropic"); + NodeAssert.equal(provider.name, "anthropic"); + NodeAssert.equal(Object.keys(provider.models).length, 1); + + const model = provider.models["claude-sonnet-4-5"]!; + NodeAssert.ok(model); + NodeAssert.equal(model.id, "claude-sonnet-4-5"); + NodeAssert.equal(model.providerID, "anthropic"); + NodeAssert.equal(model.name, "Claude Sonnet 4.5"); + }); + + it("parses multiple models from multiple providers", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }), + "anthropic/claude-haiku-4-5", + JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), + "openai/gpt-4o", + JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 2); + NodeAssert.equal(result.connected.length, 2); + NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); + NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2); + NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1); + }); + + it("handles empty input", () => { + const result = parseModelsCliOutput(""); + NodeAssert.equal(result.providers.size, 0); + NodeAssert.equal(result.connected.length, 0); + }); + + it("skips unparseable JSON blocks", () => { + const stdout = [ + "anthropic/claude-sonnet-4-5", + "this is not valid json {{{", + "anthropic/claude-haiku-4-5", + JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + const provider = result.providers.get("anthropic")!; + NodeAssert.equal(Object.keys(provider.models).length, 1); + NodeAssert.ok(provider.models["claude-haiku-4-5"]); + }); + + it("handles Windows-style CRLF line endings", () => { + const stdout = + "anthropic/claude-sonnet-4-5\r\n" + + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + + "\r\n"; + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); + }); + + it("handles model JSON with variants and nested fields", () => { + const stdout = [ + "opencode/gpt-5.4", + JSON.stringify({ + id: "gpt-5.4", + providerID: "opencode", + name: "GPT-5.4", + family: "gpt", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200000, input: 160000, output: 32000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + variants: { none: {}, low: {}, medium: {}, high: {} }, + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + const model = result.providers.get("opencode")!.models["gpt-5.4"]!; + NodeAssert.ok(model); + NodeAssert.ok(model.capabilities); + NodeAssert.equal(model.capabilities!.reasoning, true); + NodeAssert.ok(model.variants); + NodeAssert.equal(model.variants!["medium"] !== undefined, true); + }); +}); + +describe("parseAgentListCliOutput", () => { + it("parses a single agent", () => { + const stdout = [ + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[0]!.permission.length, 1); + }); + + it("parses multiple agents", () => { + const stdout = [ + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + "plan (primary)", + " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 3); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[1]!.name, "explore"); + NodeAssert.equal(result[1]!.mode, "subagent"); + NodeAssert.equal(result[2]!.name, "plan"); + NodeAssert.equal(result[2]!.mode, "primary"); + }); + + it("handles empty input", () => { + const result = parseAgentListCliOutput(""); + NodeAssert.equal(result.length, 0); + }); + + it("skips agents with unparseable permission JSON", () => { + const stdout = [ + "build (primary)", + " not valid json {", + "explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "explore"); + }); + + it("handles real-world permission blocks with nested paths", () => { + const permissions = [ + { permission: "*", action: "allow", pattern: "*" }, + { + permission: "external_directory", + pattern: "C:\\Users\\test\\.local\\*", + action: "allow", + }, + { permission: "read", pattern: "*.env", action: "ask" }, + ]; + const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.permission.length, 3); + NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); + NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); + }); + + it("handles agent names with spaces", () => { + const stdout = [ + "code reviewer (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + "my custom agent (primary)", + " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 2); + NodeAssert.equal(result[0]!.name, "code reviewer"); + NodeAssert.equal(result[0]!.mode, "subagent"); + NodeAssert.equal(result[1]!.name, "my custom agent"); + NodeAssert.equal(result[1]!.mode, "primary"); + }); + + it("marks known hidden agents", () => { + const stdout = [ + "compaction (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result[0]!.hidden, true); + NodeAssert.equal(result[1]!.hidden, false); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 46d0b3019bd..b853662b037 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -5,6 +5,7 @@ import { createOpencodeClient, type Agent, type FilePartInput, + type Model, type OpencodeClient, type PermissionRuleset, type ProviderListResponse, @@ -147,6 +148,10 @@ export interface OpenCodeRuntimeShape { readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; + readonly loadInventoryFromCli: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; } function parseServerUrlFromOutput(output: string): string | null { @@ -160,6 +165,113 @@ function parseServerUrlFromOutput(output: string): string | null { return null; } +const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; +const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; + +// Agents that are always hidden in OpenCode but the CLI "agent list" command +// does not expose the hidden flag. Keep in sync with OpenCode agent +// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). +const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); + +/** @internal */ +export function parseModelsCliOutput(stdout: string): { + readonly providers: ReadonlyMap< + string, + { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } + >; + readonly connected: ReadonlyArray; +} { + const providers = new Map< + string, + { id: string; name: string; models: { [key: string]: Model } } + >(); + const lines = stdout.split("\n"); + let currentSlug: string | null = null; + const jsonLines: Array = []; + + const flushModel = () => { + if (currentSlug !== null && jsonLines.length > 0) { + const jsonStr = jsonLines.join("\n").trim(); + if (jsonStr.length > 0) { + try { + const model = JSON.parse(jsonStr) as Model; + const separator = currentSlug.indexOf("/"); + if (separator > 0) { + const providerID = currentSlug.slice(0, separator); + const modelID = currentSlug.slice(separator + 1); + let provider = providers.get(providerID); + if (!provider) { + provider = { id: providerID, name: providerID, models: {} }; + providers.set(providerID, provider); + } + provider.models[modelID] = model; + } + } catch { + // Skip unparseable model JSON + } + } + } + currentSlug = null; + jsonLines.length = 0; + }; + + for (const line of lines) { + const slugMatch = SLUG_LINE_RE.exec(line); + if (slugMatch) { + flushModel(); + currentSlug = slugMatch[1]!; + } else if (currentSlug !== null) { + jsonLines.push(line); + } + } + flushModel(); + + return { providers, connected: [...providers.keys()] }; +} + +/** @internal */ +export function parseAgentListCliOutput(stdout: string): ReadonlyArray { + const agents: Array = []; + const lines = stdout.split("\n"); + let currentHeader: { name: string; mode: string } | null = null; + const blockLines: Array = []; + + const flushAgent = () => { + if (currentHeader !== null) { + const jsonStr = blockLines.join("\n").trim(); + if (jsonStr.length > 0) { + try { + const permission = JSON.parse(jsonStr); + agents.push({ + name: currentHeader.name, + mode: currentHeader.mode as Agent["mode"], + hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), + permission, + options: {}, + }); + } catch { + // Skip unparseable agent + } + } + } + currentHeader = null; + blockLines.length = 0; + }; + + for (const line of lines) { + const match = AGENT_HEADER_RE.exec(line); + if (match) { + flushAgent(); + currentHeader = { name: match[1]!, mode: match[2]! }; + } else if (currentHeader !== null) { + blockLines.push(line); + } + } + flushAgent(); + + return agents; +} + export function parseOpenCodeModelSlug( slug: string | null | undefined, ): ParsedOpenCodeModelSlug | null { @@ -542,12 +654,76 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map(([providerList, agents]) => ({ providerList, agents })), ); + const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => + Effect.gen(function* () { + const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); + + const runModelsCli = () => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["models", "--verbose"], + ...env, + }).pipe(Effect.exit); + const runAgentsCli = () => + runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["agent", "list"], ...env }).pipe( + Effect.exit, + ); + + // First attempt — run both in parallel + let [modelsResult, agentsResult] = yield* Effect.all([runModelsCli(), runAgentsCli()], { + concurrency: "unbounded", + }); + + // Retry once after 1s on transient failures (e.g. SQLite "database is locked") + const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; + const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; + if (needsModelsRetry || needsAgentsRetry) { + yield* Effect.sleep("1 second"); + const [m2, a2] = yield* Effect.all( + [ + needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), + needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), + ], + { concurrency: "unbounded" }, + ); + modelsResult = m2; + agentsResult = a2; + } + + // Degrade gracefully on failure — return empty inventory (warning status, not error) + let connected: string[] = []; + let allProviders: ProviderListResponse["all"] = []; + if (modelsResult._tag === "Success" && modelsResult.value.code === 0) { + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + connected = [...parsed.connected]; + allProviders = [...parsed.providers.values()].map((p) => ({ + id: p.id, + name: p.name, + source: "config" as const, + env: [], + options: {}, + models: p.models, + })); + } + + let agents: ReadonlyArray = []; + if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { + agents = parseAgentListCliOutput(agentsResult.value.stdout); + } + + return { + providerList: { all: allProviders, default: {}, connected }, + agents, + }; + }); + return { startOpenCodeServerProcess, connectToOpenCodeServer, runOpenCodeCommand, createOpenCodeSdkClient, loadOpenCodeInventory, + loadInventoryFromCli, } satisfies OpenCodeRuntimeShape; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 558a8663b64..1fcf9bc4c73 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -107,6 +107,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadInventoryFromCli: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", + cause: null, + }), + ), }; const DEFAULT_TEST_MODEL_SELECTION = { From 946b867666be94482e9e41015a304f55fe1e4754 Mon Sep 17 00:00:00 2001 From: xxashxx-svg Date: Mon, 20 Jul 2026 16:46:47 +0530 Subject: [PATCH 021/110] fix(web): scope timeline minimap hover target to the side gutter (#3869) Co-authored-by: Claude Fable 5 --- .../components/chat/MessagesTimeline.logic.ts | 39 +++++++++++ .../components/chat/MessagesTimeline.test.tsx | 24 +++++++ .../src/components/chat/MessagesTimeline.tsx | 65 ++++++++++++++----- 3 files changed, 110 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c6e277cce08..3227bac2413 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -64,6 +64,45 @@ export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number) return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; } +export const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; +export const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; +export const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; + +/** + * The minimap overlays the viewport's left edge while the content column is + * centered, so the side gutter between them shrinks under browser zoom or a + * narrow pane. A fixed-width hover strip would then sit on top of the message + * text and swallow its pointer events. Cap the strip's width so it never + * extends past the gutter into the content column; 0 disables the strip. + */ +export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number { + if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { + return 0; + } + + const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH); + const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2); + return Math.max( + 0, + Math.min( + TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH, + Math.floor(sideGutter) - TIMELINE_MINIMAP_HIT_STRIP_LEFT, + ), + ); +} + +/** + * Once the preview is open, keep the full preview and the space leading to it + * interactive. The collapsed strip remains gutter-capped so it cannot block + * selecting message text. + */ +export function resolveTimelineMinimapInteractiveWidth( + collapsedWidth: number, + expanded: boolean, +): number | string { + return expanded ? TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH : collapsedWidth; +} + function computeElapsedMs(startIso: string, endIso: string): number | null { const start = Date.parse(startIso); const end = Date.parse(endIso); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0957e025311..a58724f3308 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -224,7 +224,9 @@ describe("MessagesTimeline", () => { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, + resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); @@ -254,6 +256,28 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); + + // No usable gutter (zoomed in / narrow pane): the strip must go inert + // instead of overlaying the centered content column. + expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0); + // Partial gutter: strip shrinks to what fits between the viewport edge + // and the content column. + expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14); + // Full gutter: unchanged 40px-wide strip. + expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0); + + // The collapsed target stays narrow, but an open preview keeps its full + // 20rem width plus the 2rem offset from the minimap rail interactive. + expect(resolveTimelineMinimapInteractiveWidth(0, false)).toBe(0); + expect(resolveTimelineMinimapInteractiveWidth(14, false)).toBe(14); + expect(resolveTimelineMinimapInteractiveWidth(40, false)).toBe(40); + expect(resolveTimelineMinimapInteractiveWidth(0, true)).toBe("22rem"); + expect(resolveTimelineMinimapInteractiveWidth(14, true)).toBe("22rem"); + expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); }); it("anchors a sent attachment message using its measured height", async () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 46d6a4050b8..73fc86312e5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -73,7 +73,9 @@ import { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, + resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, + resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, type StableMessagesTimelineRowsState, type MessagesTimelineRow, @@ -324,6 +326,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); + const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -395,6 +398,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); + setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); }; const frame = requestAnimationFrame(measure); @@ -511,6 +515,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ items={minimapItems} bottomInset={contentInsetEndAdjustment} hasPersistentGutter={minimapHasPersistentGutter} + hitStripWidth={minimapHitStripWidth} stripMap={minimapStripMap} onSelect={(item) => { onManualNavigation(); @@ -605,15 +610,21 @@ function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number return typeof height === "number" && Number.isFinite(height) ? height : null; } +function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { + return target instanceof Element && target.closest("[data-minimap-preview]") !== null; +} + function TimelineMinimap({ bottomInset, hasPersistentGutter, + hitStripWidth, items, stripMap, onSelect, }: { bottomInset: number; hasPersistentGutter: boolean; + hitStripWidth: number; items: ReadonlyArray; stripMap: Map; onSelect: (item: TimelineMinimapItem) => void; @@ -676,7 +687,7 @@ function TimelineMinimap({ return ( ); }); From c710167bde19e665c8517b606718bfbd406f2dd1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 20 Jul 2026 07:30:16 -0400 Subject: [PATCH 023/110] fix(web): paint text selection over composer chips (#4139) Signed-off-by: Yordis Prieto --- .../src/components/ComposerPromptEditor.tsx | 84 ++++++++++++++++++- apps/web/src/index.css | 12 +++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 585d46150a8..169126788ae 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -28,7 +28,10 @@ import { KEY_ENTER_COMMAND, KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, + BLUR_COMMAND, + FOCUS_COMMAND, $getRoot, HISTORY_MERGE_TAG, DecoratorNode, @@ -185,7 +188,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -323,7 +326,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -394,7 +397,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -1167,6 +1170,80 @@ function ComposerInlineTokenBackspacePlugin() { return null; } +/** + * Chips render as non-editable decorators, so the browser never paints the + * native text selection over them; without help, a selection spanning chips + * is only visible in the slivers between them. Mirror the selection onto the + * chips with a data attribute the stylesheet turns into a highlight overlay. + */ +function ComposerChipSelectionPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + let selectedKeys = new Set(); + // Lexical keeps the range selection on blur without emitting an update, + // so focus is tracked separately; while blurred the native highlight is + // gone and the mirrored one has to go with it. + let hasFocus = editor.getRootElement() === document.activeElement; + + const applyKeys = (nextKeys: Set) => { + for (const key of selectedKeys) { + if (!nextKeys.has(key)) { + editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected"); + } + } + for (const key of nextKeys) { + editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true"); + } + selectedKeys = nextKeys; + }; + + const readSelectedKeys = () => { + const nextKeys = new Set(); + editor.getEditorState().read(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) && !selection.isCollapsed()) { + for (const node of selection.getNodes()) { + if (node instanceof DecoratorNode) { + nextKeys.add(node.getKey()); + } + } + } + }); + return nextKeys; + }; + + const unregisterUpdate = editor.registerUpdateListener(() => { + applyKeys(hasFocus ? readSelectedKeys() : new Set()); + }); + const unregisterFocus = editor.registerCommand( + FOCUS_COMMAND, + () => { + hasFocus = true; + applyKeys(readSelectedKeys()); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + const unregisterBlur = editor.registerCommand( + BLUR_COMMAND, + () => { + hasFocus = false; + applyKeys(new Set()); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + return () => { + unregisterUpdate(); + unregisterFocus(); + unregisterBlur(); + }; + }, [editor]); + + return null; +} + function ComposerInlineTokenPastePlugin() { const [editor] = useLexicalComposerContext(); @@ -1701,6 +1778,7 @@ function ComposerPromptEditorInner({ +
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 77a5f027c13..41abcbd3b17 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1067,3 +1067,15 @@ label:has(> select#reasoning-effort) select { .dark .model-picker-list::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); } + +/* Composer chips are non-editable decorators, so the browser skips them when + painting text selection; this overlay stands in for the native highlight. */ +.composer-inline-chip[data-composer-chip-selected]::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 6px; + background-color: Highlight; + opacity: 0.3; + pointer-events: none; +} From fa69f05b69d05003a6994194c12edd1d8aee080c Mon Sep 17 00:00:00 2001 From: Maxwell Young Date: Mon, 20 Jul 2026 23:32:30 +1200 Subject: [PATCH 024/110] [codex] preserve custom model slugs (#4168) --- .../src/provider/Layers/ClaudeProvider.ts | 5 --- .../src/provider/Layers/CursorProvider.ts | 5 +-- .../src/provider/Layers/GrokProvider.ts | 9 +---- .../src/provider/Layers/OpenCodeProvider.ts | 25 ++------------ .../src/provider/providerSnapshot.test.ts | 22 +++++++++++-- apps/server/src/provider/providerSnapshot.ts | 5 ++- .../settings/ProviderModelsSection.tsx | 4 +-- apps/web/src/modelSelection.test.ts | 33 +++++++++++++++++++ apps/web/src/modelSelection.ts | 10 +++--- packages/shared/src/model.test.ts | 13 +++++++- packages/shared/src/model.ts | 15 ++++++--- 11 files changed, 88 insertions(+), 58 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 94b24405eee..ff0d1992454 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -2,7 +2,6 @@ import { type ClaudeSettings, type ModelCapabilities, type ModelSelection, - ProviderDriverKind, type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; @@ -44,7 +43,6 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili optionDescriptors: [], }); -const PROVIDER = ProviderDriverKind.make("claudeAgent"); const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, @@ -691,7 +689,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, - PROVIDER, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -782,7 +779,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const models = providerModelsFromSettings( getBuiltInClaudeModelsForVersion(parsedVersion), - PROVIDER, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -851,7 +847,6 @@ export const makePendingClaudeProvider = ( const checkedAt = yield* nowIso; const models = providerModelsFromSettings( BUILT_IN_MODELS, - PROVIDER, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 063f2c1bf2b..fee4306c4c5 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -8,7 +8,6 @@ import type { ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; -import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as Crypto from "effect/Crypto"; @@ -51,7 +50,6 @@ import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts" const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( CursorListAvailableModelsResponse, ); -const PROVIDER = ProviderDriverKind.make("cursor"); const CURSOR_PRESENTATION = { displayName: "Cursor", badgeLabel: "Early Access", @@ -576,7 +574,7 @@ export const discoverCursorModelsViaAcp = ( export function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { - return providerModelsFromSettings([], PROVIDER, cursorSettings.customModels, EMPTY_CAPABILITIES); + return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); } /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ @@ -638,7 +636,6 @@ export function buildCursorProviderSnapshot(input: { checkedAt: input.checkedAt, models: providerModelsFromSettings( input.discoveredModels ?? [], - PROVIDER, input.cursorSettings.customModels, EMPTY_CAPABILITIES, ), diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 33f61ad97f6..934eecdb5ae 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -1,7 +1,6 @@ import { type GrokSettings, type ModelCapabilities, - ProviderDriverKind, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; @@ -38,7 +37,6 @@ const GROK_PRESENTATION = { showInteractionModeToggle: false, requiresNewThreadForModelChange: true, } as const; -const PROVIDER = ProviderDriverKind.make("grok"); const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); @@ -98,12 +96,7 @@ function grokModelsFromSettings( customModels: ReadonlyArray | undefined, builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, ): ReadonlyArray { - return providerModelsFromSettings( - builtInModels, - PROVIDER, - customModels ?? [], - EMPTY_CAPABILITIES, - ); + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } function buildGrokDiscoveredModelsFromSessionModelState( diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 2c63350014d..21014e33f08 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -1,5 +1,4 @@ import { - ProviderDriverKind, type ModelCapabilities, type OpenCodeSettings, type ServerProviderModel, @@ -25,7 +24,6 @@ import { } from "../opencodeRuntime.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; -const PROVIDER = ProviderDriverKind.make("opencode"); const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, @@ -259,7 +257,6 @@ export const makePendingOpenCodeProvider = ( const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); const models = providerModelsFromSettings( [], - PROVIDER, openCodeSettings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); @@ -319,12 +316,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { installed: failure.installed, version, @@ -340,12 +332,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { installed: false, version: null, @@ -391,12 +378,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { installed: true, version, @@ -444,7 +426,6 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const models = providerModelsFromSettings( flattenOpenCodeModels(inventoryExit.value), - PROVIDER, customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index abe138fdfb9..01157278066 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; +import type { ModelCapabilities } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; @@ -38,7 +38,6 @@ describe("providerModelsFromSettings", () => { it("applies the provided capabilities to custom models", () => { const models = providerModelsFromSettings( [], - ProviderDriverKind.make("opencode"), ["openai/gpt-5"], OPENCODE_CUSTOM_MODEL_CAPABILITIES, ); @@ -52,6 +51,25 @@ describe("providerModelsFromSettings", () => { }, ]); }); + + it("preserves a custom slug that collides with a provider alias", () => { + const capabilities = createModelCapabilities({ optionDescriptors: [] }); + const models = providerModelsFromSettings( + [ + { + slug: "claude-opus-4-8", + name: "Claude Opus 4.8", + isCustom: false, + capabilities, + }, + ], + [" opus "], + capabilities, + ); + + expect(models.map((model) => model.slug)).toEqual(["claude-opus-4-8", "opus"]); + expect(models[1]?.isCustom).toBe(true); + }); }); describe("ProviderCommandNotFoundError", () => { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index dfe31ffdc44..e741a7a2c1d 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -13,7 +13,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { normalizeCustomModelSlug } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -140,7 +140,6 @@ export function parseGenericCliVersion(output: string): string | null { export function providerModelsFromSettings( builtInModels: ReadonlyArray, - provider: ProviderDriverKind, customModels: ReadonlyArray, customModelCapabilities: ModelCapabilities, ): ReadonlyArray { @@ -149,7 +148,7 @@ export function providerModelsFromSettings( const customEntries: ServerProviderModel[] = []; for (const candidate of customModels) { - const normalized = normalizeModelSlug(candidate, provider); + const normalized = normalizeCustomModelSlug(candidate); if (!normalized || seen.has(normalized)) { continue; } diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index f1100ab93d2..27d13997552 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -16,7 +16,7 @@ import { type ProviderInstanceId, type ServerProviderModel, } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { normalizeCustomModelSlug } from "@t3tools/shared/model"; import { cn } from "../../lib/utils"; import { sortModelsForProviderInstance } from "../../modelOrdering"; @@ -111,7 +111,7 @@ export function ProviderModelsSection({ }, [favoriteModelSet, modelOrder, models]); const handleAdd = () => { - const normalized = driverKind ? normalizeModelSlug(input, driverKind) : input.trim() || null; + const normalized = normalizeCustomModelSlug(input); if (!normalized) { setError("Enter a model slug."); return; diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 3d973ccca74..e8fc8a244d0 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -101,6 +101,39 @@ describe("instance-scoped model selection", () => { ).toBe("openai/gpt-5.5"); }); + it("preserves a custom slug that collides with a provider alias", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claude_openrouter", + models: ["claude-opus-4-8"], + }), + ]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerInstances: { + ...settingsWithProviderInstances().providerInstances, + [ProviderInstanceId.make("claude_openrouter")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { customModels: ["opus"] }, + }, + }, + }; + const openrouter = deriveProviderInstanceEntries(providers)[0]!; + + expect( + getAppModelOptionsForInstance(settings, openrouter).map((option) => option.slug), + ).toEqual(["claude-opus-4-8", "opus"]); + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claude_openrouter"), + settings, + providers, + "opus", + ), + ).toBe("opus"); + }); + it("includes Grok custom models from the selected provider instance", () => { const providers = [provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" })]; const settings: UnifiedSettings = { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 7ede9665ac9..f1d527880ed 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -9,7 +9,7 @@ import { } from "@t3tools/contracts"; import { createModelSelection, - normalizeModelSlug, + normalizeCustomModelSlug, resolveSelectableModel, } from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderState"; @@ -117,13 +117,12 @@ function applyInstanceModelPreferences( export function normalizeCustomModelSlugs( models: Iterable, builtInModelSlugs: ReadonlySet, - provider: ProviderDriverKind = ProviderDriverKind.make("codex"), ): string[] { const normalizedModels: string[] = []; const seen = new Set(); for (const candidate of models) { - const normalized = normalizeModelSlug(candidate, provider); + const normalized = normalizeCustomModelSlug(candidate); if ( !normalized || normalized.length > MAX_CUSTOM_MODEL_LENGTH || @@ -163,7 +162,7 @@ export function getAppModelOptions( // see the user's authored custom models. const defaultInstanceId = defaultInstanceIdForDriver(provider); const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); - for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, provider)) { + for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { if (seen.has(slug)) { continue; } @@ -206,8 +205,7 @@ export function getAppModelOptionsForInstance( ); const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); - const normalizer = entry.driverKind; - for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, normalizer)) { + for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { if (seen.has(slug)) { continue; } diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index ee91e0b53f7..b055c255bd7 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, @@ -10,6 +10,8 @@ import { getProviderOptionDescriptors, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, + normalizeCustomModelSlug, + normalizeModelSlug, } from "./model.ts"; const codexCaps: ModelCapabilities = createModelCapabilities({ @@ -144,3 +146,12 @@ describe("descriptor helpers", () => { expect(getModelSelectionBooleanOptionValue(selection, "fastMode")).toBe(true); }); }); + +describe("model slug normalization", () => { + it("preserves exact custom slugs instead of expanding provider aliases", () => { + const claude = ProviderDriverKind.make("claudeAgent"); + + expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-4-8"); + expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 9fdbd6c0447..bdc0c0cc8ef 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -236,11 +236,7 @@ export function normalizeModelSlug( model: string | null | undefined, provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND, ): string | null { - if (typeof model !== "string") { - return null; - } - - const trimmed = model.trim(); + const trimmed = normalizeCustomModelSlug(model); if (!trimmed) { return null; } @@ -252,6 +248,15 @@ export function normalizeModelSlug( return typeof aliased === "string" ? aliased : trimmed; } +/** Custom model identifiers are provider-owned, so only trim them; never expand aliases. */ +export function normalizeCustomModelSlug(model: string | null | undefined): string | null { + if (typeof model !== "string") { + return null; + } + + return model.trim() || null; +} + export function resolveSelectableModel( provider: ProviderDriverKind, value: string | null | undefined, From 0936fd271f866f5f58cce067865c8d0262b9ea20 Mon Sep 17 00:00:00 2001 From: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:22:03 +0200 Subject: [PATCH 025/110] fix(web): preview workspace images in the file panel (#3996) Co-authored-by: Rhiz3K Co-authored-by: Julius Marminge --- apps/web/src/assets/assetUrls.ts | 24 ++++++++- .../src/components/files/FilePreviewPanel.tsx | 52 ++++++++++++++++++- .../files/projectFilesQueryState.ts | 10 +++- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 673b093e333..701af3a79fc 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -9,7 +9,15 @@ import { usePreparedConnection } from "~/state/session"; export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { +export type AssetUrlState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Failure" } + | { readonly _tag: "Success"; readonly url: string }; + +export function useAssetUrlState( + environmentId: EnvironmentId, + resource: AssetResource, +): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( assetEnvironment.createUrl({ @@ -17,10 +25,22 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc input: { resource }, }), ); + if (result._tag === "Failure") { + return { _tag: "Failure" }; + } if (preparedConnection._tag === "None" || result._tag !== "Success") { + return { _tag: "Loading" }; + } + const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; +} + +export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { + const result = useAssetUrlState(environmentId, resource); + if (result._tag !== "Success") { return null; } - return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return result.url; } export function useAssetUrls( diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 8c430d5d2ab..6ddd38e9d25 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -4,6 +4,7 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; @@ -16,6 +17,7 @@ import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; +import { useAssetUrlState } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useClientSettings } from "~/hooks/useSettings"; @@ -113,6 +115,43 @@ const FILE_LINK_REVEAL_UNSAFE_CSS = ` `; type FilePostRender = NonNullable["onPostRender"]>; +function WorkspaceImagePreview(props: { + readonly environmentId: EnvironmentId; + readonly threadRef: ScopedThreadRef; + readonly absolutePath: string; + readonly alt: string; +}) { + const assetUrl = useAssetUrlState(props.environmentId, { + _tag: "workspace-file", + threadId: props.threadRef.threadId, + path: props.absolutePath, + }); + const [failedUrl, setFailedUrl] = useState(null); + + if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { + return ( +
+ Unable to load workspace image. +
+ ); + } + + return assetUrl._tag === "Success" ? ( +
+ {props.alt} setFailedUrl(assetUrl.url)} + /> +
+ ) : ( +
+ +
+ ); +} + function clampFileLine(contents: string, requestedLine: number): number { let lineCount = 1; for (let index = 0; index < contents.length; index += 1) { @@ -630,7 +669,8 @@ export default function FilePreviewPanel({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const file = useProjectFileQuery(environmentId, cwd, relativePath); + const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath); + const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); const [markdownView, setMarkdownView] = useState<{ path: string | null; @@ -818,7 +858,15 @@ export default function FilePreviewPanel({ relativePath ? "flex" : "hidden", )} > - {relativePath && file.error && file.data === null ? ( + {relativePath && isImage && absolutePath ? ( + + ) : relativePath && file.error && file.data === null ? (
{file.error}
diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 191b97d6a96..0d3fb8dd941 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -6,7 +6,7 @@ import type { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; -import { AsyncResult } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; @@ -14,6 +14,9 @@ import { projectEnvironment } from "~/state/projects"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; const EMPTY_PROJECT_FILE_PATH = ""; +const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( + AsyncResult.initial(false), +).pipe(Atom.withLabel("project-file-query:empty")); function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) { return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); } @@ -137,8 +140,11 @@ export function useProjectFileQuery( environmentId: EnvironmentId, cwd: string, relativePath: string | null, + enabled = true, ): ProjectQueryState { - const atom = getProjectFileQueryAtom(environmentId, cwd, relativePath); + const atom = enabled + ? getProjectFileQueryAtom(environmentId, cwd, relativePath) + : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); From 8ca4eec9ca46e1dd2f2427ae2405f5f417e8f95d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 20 Jul 2026 08:26:46 -0400 Subject: [PATCH 026/110] feat(web): drag files from the explorer into the chat composer (#4140) Co-authored-by: Julius Marminge Signed-off-by: Yordis Prieto --- apps/web/src/components/chat/ChatComposer.tsx | 90 ++++++++++--- .../chat/composerMentionDrag.test.ts | 123 ++++++++++++++++++ .../components/chat/composerMentionDrag.ts | 98 ++++++++++++++ .../src/components/files/FileBrowserPanel.tsx | 44 +++++++ .../files/fileTreeDragMention.test.ts | 112 ++++++++++++++++ .../components/files/fileTreeDragMention.ts | 95 ++++++++++++++ 6 files changed, 542 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/components/chat/composerMentionDrag.test.ts create mode 100644 apps/web/src/components/chat/composerMentionDrag.ts create mode 100644 apps/web/src/components/files/fileTreeDragMention.test.ts create mode 100644 apps/web/src/components/files/fileTreeDragMention.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6ec2e631d19..f3346b4100a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,6 +44,10 @@ import { shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + dataTransferHasComposerMention, + makeComposerMentionDragHandlers, +} from "./composerMentionDrag"; import { type ComposerImageAttachment, type DraftId, @@ -1878,6 +1882,67 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerImages(files); focusComposer(); }; + + const insertComposerTextAtEnd = ( + text: string, + options?: { ensureLeadingBoundary?: boolean }, + ): boolean => { + if ( + text.length === 0 || + isConnecting || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired || + (environmentUnavailable !== null && activePendingProgress === null) + ) { + return false; + } + const prompt = promptRef.current; + const needsLeadingSpace = + (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); + return applyPromptReplacement( + prompt.length, + prompt.length, + needsLeadingSpace ? ` ${text}` : text, + ); + }; + + // File-tree drags land as mentions. Handled in the capture phase so the + // editor never sees the drop; the load-bearing rules (native stop, "move" + // effect, no eager focus) live in makeComposerMentionDragHandlers. + const composerMentionDragHandlers = makeComposerMentionDragHandlers({ + insertMentionAtEnd: (text) => insertComposerTextAtEnd(text, { ensureLeadingBoundary: true }), + setDragActive: setIsDragOverComposer, + onInsertRejected: () => { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + }, + }); + + const onComposerMentionDragLeaveCapture = (event: React.DragEvent) => { + if (!dataTransferHasComposerMention(event.dataTransfer.types)) return; + event.stopPropagation(); + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + setIsDragOverComposer(false); + }; + + // A cancelled drag (Escape) can end without a dragleave on the hovered + // target, which would leave the drop highlight stuck. dragend always fires + // on the in-page drag source and bubbles to window, so it is the reset of + // last resort while the highlight is up. + useEffect(() => { + if (!isDragOverComposer) return; + const onWindowDragEnd = () => { + dragDepthRef.current = 0; + setIsDragOverComposer(false); + }; + window.addEventListener("dragend", onWindowDragEnd); + return () => window.removeEventListener("dragend", onWindowDragEnd); + }, [isDragOverComposer]); const handleInterruptPrimaryAction = useCallback(() => { void onInterrupt(); }, [onInterrupt]); @@ -1941,26 +2006,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, - insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => { - if ( - text.length === 0 || - isConnecting || - isComposerApprovalState || - pendingUserInputs.length > 0 || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) - ) { - return false; - } - const prompt = promptRef.current; - const needsLeadingSpace = - (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); - return applyPromptReplacement( - prompt.length, - prompt.length, - needsLeadingSpace ? ` ${text}` : text, - ); - }, + insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { setIsComposerModelPickerOpen(true); }, @@ -2087,6 +2133,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onDragOver={onComposerDragOver} onDragLeave={onComposerDragLeave} onDrop={onComposerDrop} + onDragEnterCapture={composerMentionDragHandlers.onDragEnter} + onDragOverCapture={composerMentionDragHandlers.onDragOver} + onDragLeaveCapture={onComposerMentionDragLeaveCapture} + onDropCapture={composerMentionDragHandlers.onDrop} >
}) => { + const mention = options?.mention ?? "[index.md](docs/index.md)"; + const calls: Array = []; + const event = { + dataTransfer: { + types: options?.types ?? [COMPOSER_MENTION_DRAG_TYPE, "text/plain"], + getData: (format: string) => (format === COMPOSER_MENTION_DRAG_TYPE ? mention : ""), + dropEffect: "none", + }, + nativeEvent: { + stopPropagation: () => void calls.push("nativeStopPropagation"), + }, + preventDefault: () => void calls.push("preventDefault"), + stopPropagation: () => void calls.push("stopPropagation"), + }; + return { event, calls }; +}; + +const makeHost = (insertResult = true) => { + const log: Array = []; + const host: ComposerMentionDropHost = { + insertMentionAtEnd: (text) => { + log.push(`insert:${text}`); + return insertResult; + }, + setDragActive: (active) => void log.push(`active:${active}`), + onInsertRejected: () => void log.push("rejected"), + }; + return { host, log }; +}; + +describe("composerMentionFromTreePath", () => { + it("serializes a file path into a mention", () => { + expect(composerMentionFromTreePath("docs/index.md")).toBe("[index.md](docs/index.md)"); + }); + + it("strips the trailing slash directory rows carry", () => { + expect(composerMentionFromTreePath("docs/architecture/")).toBe( + "[architecture](docs/architecture)", + ); + }); + + it("rejects drags that carry no path", () => { + expect(composerMentionFromTreePath("")).toBeNull(); + expect(composerMentionFromTreePath("/")).toBeNull(); + }); +}); + +describe("dataTransferHasComposerMention", () => { + it("detects the mention payload among drag types", () => { + expect(dataTransferHasComposerMention([COMPOSER_MENTION_DRAG_TYPE, "text/plain"])).toBe(true); + expect(dataTransferHasComposerMention(["Files"])).toBe(false); + expect(dataTransferHasComposerMention([])).toBe(false); + }); +}); + +describe("makeComposerMentionDragHandlers", () => { + it("leaves drags without the mention payload alone", () => { + const { host, log } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + const { event, calls } = makeDragEvent({ types: ["Files"] }); + handlers.onDragEnter(event); + handlers.onDragOver(event); + handlers.onDrop(event); + expect(calls).toEqual([]); + expect(log).toEqual([]); + }); + + it("stops the native event too, not just the synthetic one", () => { + // React's stopPropagation only halts synthetic dispatch; without the + // native stop, the editor's own DOM listeners process the drop and sync + // their stale state back over the inserted mention. + const { host } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + const { event, calls } = makeDragEvent(); + handlers.onDrop(event); + expect(calls).toContain("preventDefault"); + expect(calls).toContain("stopPropagation"); + expect(calls).toContain("nativeStopPropagation"); + }); + + it('answers dragover with the "move" effect the tree allows', () => { + // Naming an effect outside the source's effectAllowed makes the browser + // cancel the drop without ever firing it. + const { host } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + const { event } = makeDragEvent(); + handlers.onDragOver(event); + expect(event.dataTransfer.dropEffect).toBe("move"); + }); + + it("inserts the mention with its trailing space and clears the highlight", () => { + const { host, log } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + handlers.onDragEnter(makeDragEvent().event); + handlers.onDrop(makeDragEvent().event); + expect(log).toEqual(["active:true", "active:false", "insert:[index.md](docs/index.md) "]); + }); + + it("reports a rejected insert instead of failing silently", () => { + const { host, log } = makeHost(false); + const handlers = makeComposerMentionDragHandlers(host); + handlers.onDrop(makeDragEvent().event); + expect(log).toContain("rejected"); + }); + + it("ignores a drop whose payload is empty", () => { + const { host, log } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + handlers.onDrop(makeDragEvent({ mention: "" }).event); + expect(log).toEqual(["active:false"]); + }); +}); diff --git a/apps/web/src/components/chat/composerMentionDrag.ts b/apps/web/src/components/chat/composerMentionDrag.ts new file mode 100644 index 00000000000..43b1bfd800d --- /dev/null +++ b/apps/web/src/components/chat/composerMentionDrag.ts @@ -0,0 +1,98 @@ +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; + +/** + * Drag payload type carrying a serialized composer mention. Set on drags that + * start in the workspace file tree so the composer can tell them apart from + * OS file drags and plain text selections. + */ +export const COMPOSER_MENTION_DRAG_TYPE = "application/x-t3code-composer-mention"; + +export function composerMentionFromTreePath(treePath: string): string | null { + const relativePath = treePath.replace(/\/+$/, ""); + if (relativePath.length === 0) { + return null; + } + return serializeComposerFileLink(relativePath); +} + +export function dataTransferHasComposerMention(types: ReadonlyArray): boolean { + return types.includes(COMPOSER_MENTION_DRAG_TYPE); +} + +export interface ComposerMentionDragTransfer { + readonly types: ReadonlyArray; + getData(format: string): string; + dropEffect: string; +} + +export interface ComposerMentionDragEvent { + readonly dataTransfer: ComposerMentionDragTransfer; + readonly nativeEvent: { stopPropagation(): void }; + preventDefault(): void; + stopPropagation(): void; +} + +/** + * What a mention drop is allowed to do to the composer. Deliberately narrow: + * there is no way to focus the editor from here. Focusing it synchronously + * during the drop makes the not-yet-reconciled editor sync its stale empty + * state back over the inserted mention; the insert path already focuses on + * the next frame, after the editor has caught up. + */ +export interface ComposerMentionDropHost { + insertMentionAtEnd(text: string): boolean; + setDragActive(active: boolean): void; + onInsertRejected(): void; +} + +export interface ComposerMentionDragHandlers { + onDragEnter(event: ComposerMentionDragEvent): void; + onDragOver(event: ComposerMentionDragEvent): void; + onDrop(event: ComposerMentionDragEvent): void; +} + +export function makeComposerMentionDragHandlers( + host: ComposerMentionDropHost, +): ComposerMentionDragHandlers { + // Claim the event for the composer: React's stopPropagation only halts the + // synthetic dispatch, so the native event must be stopped too or the + // editor's own DOM listeners still process the drag. + const claim = (event: ComposerMentionDragEvent): boolean => { + if (!dataTransferHasComposerMention(event.dataTransfer.types)) { + return false; + } + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopPropagation(); + return true; + }; + return { + onDragEnter(event) { + if (claim(event)) { + host.setDragActive(true); + } + }, + onDragOver(event) { + if (!claim(event)) { + return; + } + // The tree constrains its drags to effectAllowed "move"; naming any + // other effect makes the browser cancel the drop without firing it. + event.dataTransfer.dropEffect = "move"; + host.setDragActive(true); + }, + onDrop(event) { + if (!claim(event)) { + return; + } + host.setDragActive(false); + const mention = event.dataTransfer.getData(COMPOSER_MENTION_DRAG_TYPE); + if (mention.length === 0) { + return; + } + if (!host.insertMentionAtEnd(`${mention} `)) { + host.onInsertRejected(); + } + }, + }; +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index f95022c3424..3f53d65533d 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -16,6 +16,7 @@ import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; interface FileBrowserPanelProps { @@ -137,6 +138,14 @@ export default function FileBrowserPanel({ showEntryContextMenuRef.current = showEntryContextMenu; }); + const treeModelRef = useRef["model"] | null>(null); + const dragMention = useMemo( + () => + createFileTreeDragMentionController({ + deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(), + }), + [], + ); const { model } = useFileTree({ composition: { contextMenu: { @@ -146,12 +155,21 @@ export default function FileBrowserPanel({ }, }, }, + // Rows only need to be draggable so entries can be dropped into the chat + // composer; rearranging files inside the tree stays off. + dragAndDrop: { canDrop: () => false }, density: "compact", fileTreeSearchMode: "hide-non-matches", flattenEmptyDirectories: true, initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { + dragMention.handleSelectionChange(selectedPaths); + // Starting a drag selects the dragged row; that selection is a side + // effect of the gesture, not a request to open the file. + if (dragMention.isDragInProgress()) { + return; + } const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { onOpenFile(selectedPath); @@ -174,8 +192,34 @@ export default function FileBrowserPanel({ [entries], ); + // Tag tree drags with the composer mention payload. The row is read from + // the composed event path (the tree's shadow root is open), so this does + // not depend on running after the tree's own dragstart handler; the drag + // data store is writable for every dragstart listener in the dispatch. + // The capture phase runs before the tree's own dragstart handler selects + // the dragged row, so the drag flag is up before that selection emits. + const panelRef = useRef(null); + useEffect(() => { + treeModelRef.current = model; + }, [model]); + useEffect(() => { + const panel = panelRef.current; + if (panel === null) { + return; + } + const handleDragStart = (event: DragEvent) => dragMention.handleDragStart(event); + const handleDragEnd = () => dragMention.handleDragEnd(); + panel.addEventListener("dragstart", handleDragStart, true); + panel.addEventListener("dragend", handleDragEnd); + return () => { + panel.removeEventListener("dragstart", handleDragStart, true); + panel.removeEventListener("dragend", handleDragEnd); + }; + }, [dragMention]); + return (
diff --git a/apps/web/src/components/files/fileTreeDragMention.test.ts b/apps/web/src/components/files/fileTreeDragMention.test.ts new file mode 100644 index 00000000000..812501ab5d1 --- /dev/null +++ b/apps/web/src/components/files/fileTreeDragMention.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { COMPOSER_MENTION_DRAG_TYPE } from "~/components/chat/composerMentionDrag"; +import { createFileTreeDragMentionController } from "./fileTreeDragMention.ts"; + +const makeTransfer = (plainText = "") => { + const data = new Map([["text/plain", plainText]]); + return { + setData: (format: string, value: string) => void data.set(format, value), + getData: (format: string) => data.get(format) ?? "", + data, + }; +}; + +const rowNode = (path: string) => ({ + getAttribute: (name: string) => (name === "data-item-path" ? path : null), +}); + +describe("createFileTreeDragMentionController", () => { + it("tags a row drag with the mention payload and flags the drag", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [{}, rowNode("docs/index.md"), {}], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[index.md](docs/index.md)"); + expect(controller.isDragInProgress()).toBe(true); + }); + + it("strips the trailing slash from directory rows", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("docs/architecture/")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[architecture](docs/architecture)"); + }); + + it("does not tag drags of selected text from the panel chrome", () => { + // Only a drag that originates on a tree row is a mention; dragging a text + // selection also carries text/plain, and tagging it would drop an invalid + // pill into the composer. + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer("selected text"); + controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] }); + expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false); + expect(controller.isDragInProgress()).toBe(false); + }); + + it("ignores drags that carry no row path", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer(); + controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] }); + expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false); + expect(controller.isDragInProgress()).toBe(false); + }); + + it("deselects the dragged row exactly once when the drag ends", () => { + const deselected: Array = []; + const controller = createFileTreeDragMentionController({ + deselect: (path) => deselected.push(path), + }); + controller.handleDragStart({ + dataTransfer: makeTransfer(), + composedPath: () => [rowNode("src/app.ts")], + }); + controller.handleDragEnd(); + controller.handleDragEnd(); + expect(deselected).toEqual(["src/app.ts"]); + expect(controller.isDragInProgress()).toBe(false); + }); + + it("drags the whole selection when the dragged row is part of it", () => { + const deselected: Array = []; + const controller = createFileTreeDragMentionController({ + deselect: (path) => deselected.push(path), + }); + controller.handleSelectionChange(["docs/index.md", "docs/api.md", "src/app.ts"]); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("docs/api.md")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe( + "[index.md](docs/index.md) [api.md](docs/api.md) [app.ts](src/app.ts)", + ); + controller.handleDragEnd(); + expect(deselected).toEqual(["docs/index.md", "docs/api.md", "src/app.ts"]); + }); + + it("drags only the row under the cursor when it is outside the selection", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + controller.handleSelectionChange(["docs/index.md"]); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("src/app.ts")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[app.ts](src/app.ts)"); + }); + + it("does not deselect anything when no drag was started", () => { + const deselected: Array = []; + const controller = createFileTreeDragMentionController({ + deselect: (path) => deselected.push(path), + }); + controller.handleDragEnd(); + expect(deselected).toEqual([]); + }); +}); diff --git a/apps/web/src/components/files/fileTreeDragMention.ts b/apps/web/src/components/files/fileTreeDragMention.ts new file mode 100644 index 00000000000..7c17639a649 --- /dev/null +++ b/apps/web/src/components/files/fileTreeDragMention.ts @@ -0,0 +1,95 @@ +import { + COMPOSER_MENTION_DRAG_TYPE, + composerMentionFromTreePath, +} from "~/components/chat/composerMentionDrag"; + +interface FileTreeDragTransfer { + setData(format: string, data: string): void; +} + +export interface FileTreeDragStartEvent { + readonly dataTransfer: FileTreeDragTransfer | null; + composedPath(): ReadonlyArray; +} + +export interface FileTreeDragMentionHost { + /** Drop the tree's gesture-applied selection of the dragged row. */ + deselect(treePath: string): void; +} + +export interface FileTreeDragMentionController { + /** + * True from the moment a row drag starts until it ends. The tree selects + * the dragged row as part of the gesture; selection changes made while + * this is set are gesture side effects, not requests to open a file. + */ + isDragInProgress(): boolean; + /** Mirror of the tree's current selection, needed for multi-row drags. */ + handleSelectionChange(selectedPaths: ReadonlyArray): void; + handleDragStart(event: FileTreeDragStartEvent): void; + handleDragEnd(): void; +} + +const itemPathOf = (node: unknown): string | null => { + if (typeof node !== "object" || node === null) { + return null; + } + const element = node as { getAttribute?: (name: string) => string | null }; + return typeof element.getAttribute === "function" ? element.getAttribute("data-item-path") : null; +}; + +/** + * Tags file-tree drags with the composer mention payload and keeps the drag + * from acting like a click: while the drag runs, selection changes are + * suppressed, and when it ends the dragged rows are deselected so nothing is + * left highlighted and a later click on them still fires a selection change. + */ +export function createFileTreeDragMentionController( + host: FileTreeDragMentionHost, +): FileTreeDragMentionController { + let selection: ReadonlyArray = []; + let draggedPaths: ReadonlyArray = []; + return { + isDragInProgress: () => draggedPaths.length > 0, + handleSelectionChange(selectedPaths) { + selection = selectedPaths; + }, + handleDragStart(event) { + if (event.dataTransfer === null) { + return; + } + // Only drags that originate on a tree row are mentions; a text/plain + // fallback would also tag drags of selected text from the panel chrome. + let itemPath: string | null = null; + for (const node of event.composedPath()) { + itemPath = itemPathOf(node); + if (itemPath !== null) { + break; + } + } + if (itemPath === null) { + return; + } + // Same rule the tree applies to the drag itself: dragging a row that is + // part of the current selection drags the whole selection. + const dragged = selection.includes(itemPath) ? selection : [itemPath]; + const mentions = dragged + .map((path) => composerMentionFromTreePath(path)) + .filter((mention): mention is string => mention !== null); + if (mentions.length === 0) { + return; + } + draggedPaths = dragged; + event.dataTransfer.setData(COMPOSER_MENTION_DRAG_TYPE, mentions.join(" ")); + }, + handleDragEnd() { + if (draggedPaths.length === 0) { + return; + } + for (const path of draggedPaths) { + host.deselect(path); + } + draggedPaths = []; + }, + }; +} From 2c199aacac3244adc93c5f4a9c838683e93946dd Mon Sep 17 00:00:00 2001 From: Anirudh Coontoor Date: Mon, 20 Jul 2026 18:13:22 +0530 Subject: [PATCH 027/110] fix(desktop): preserve main window bounds (#3851) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/desktop/src/app/DesktopLifecycle.ts | 8 +- .../src/backend/DesktopBackendPool.test.ts | 1 + .../src/backend/DesktopServerExposure.test.ts | 1 + .../src/settings/DesktopAppSettings.test.ts | 45 ++ .../src/settings/DesktopAppSettings.ts | 77 +++ .../src/updates/DesktopUpdates.test.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 518 +++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 173 +++++- apps/web/src/components/AppSidebarLayout.tsx | 40 +- .../src/components/threadSidebarWidth.test.ts | 34 ++ apps/web/src/components/threadSidebarWidth.ts | 22 + apps/web/src/components/ui/sidebar.tsx | 12 +- 13 files changed, 917 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/threadSidebarWidth.test.ts create mode 100644 apps/web/src/components/threadSidebarWidth.ts diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index c5264332b66..f8e05915718 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -73,8 +73,14 @@ function addScopedListener>( } const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( - function* (): Effect.fn.Return { + function* (): Effect.fn.Return< + void, + never, + DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow + > { const shutdown = yield* DesktopShutdown.DesktopShutdown; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.flushMainWindowBounds; yield* shutdown.request; yield* shutdown.awaitComplete; }, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 5e6a3f5164d..fa0811d5df7 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -76,6 +76,7 @@ function makePoolLayer( showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 1a107c5c856..dcfee93778d 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -250,6 +250,7 @@ describe("DesktopServerExposure", () => { const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 70b26798266..3878b0e36ad 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,17 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + mainWindowBounds: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + }), + ), + ), + mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -91,6 +102,8 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -116,6 +129,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -215,10 +230,13 @@ describe("DesktopSettings", () => { "serverExposureMode": "network-accessible", "tailscaleServeEnabled": true, "tailscaleServePort": 8443, + "mainWindowBounds": { "x": 120, "y": 80, "width": 1280, "height": 900 }, }\n`, ); assert.deepEqual(yield* settings.load, { + mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, + mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -232,6 +250,24 @@ describe("DesktopSettings", () => { ), ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, + mainWindowMaximized: true, + serverExposureMode: "network-accessible", + }); + + const loaded = yield* settings.load; + assert.isNull(loaded.mainWindowBounds); + assert.isFalse(loaded.mainWindowMaximized); + assert.equal(loaded.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -239,12 +275,15 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); }), @@ -261,6 +300,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -286,6 +327,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -310,6 +353,8 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: true, tailscaleServePort: 443, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 6a26bf5a6e2..466c9a9b5f8 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -20,6 +20,8 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly mainWindowBounds: DesktopWindowBounds | null; + readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; @@ -48,8 +50,25 @@ export interface DesktopSettingsChange { } export const DEFAULT_TAILSCALE_SERVE_PORT = 443; +const MIN_MAIN_WINDOW_SIZE = { + width: 840, + height: 620, +} as const; +export const DesktopWindowBoundsSchema = Schema.Struct({ + x: Schema.Int, + y: Schema.Int, + width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)), + height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), +}); +export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; +export const DEFAULT_MAIN_WINDOW_SIZE = { + width: 1100, + height: 780, +} as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + mainWindowBounds: null, + mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, @@ -60,7 +79,16 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslOnly: false, }; +const DesktopWindowBoundsDocument = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); + const DesktopSettingsDocument = Schema.Struct({ + mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -81,6 +109,8 @@ type Mutable = { -readonly [K in keyof T]: T[K] }; const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); +const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema); +const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema); const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ settings, @@ -114,6 +144,10 @@ export class DesktopAppSettings extends Context.Service< { readonly load: Effect.Effect; readonly get: Effect.Effect; + readonly setMainWindowBounds: ( + bounds: DesktopWindowBounds, + isMaximized: boolean, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -158,11 +192,16 @@ function normalizeWslDistro(value: unknown): string | null { return typeof value === "string" && isValidDistroName(value) ? value : null; } +export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { + return Option.getOrNull(decodeDesktopWindowBounds(value)); +} + function normalizeDesktopSettingsDocument( parsed: DesktopSettingsDocument, appVersion: string, ): DesktopSettings { const defaultSettings = resolveDefaultDesktopSettings(appVersion); + const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds); const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; const updateChannelConfiguredByUser = @@ -177,6 +216,8 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + mainWindowBounds, + mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, @@ -197,6 +238,12 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.mainWindowBounds !== null) { + document.mainWindowBounds = settings.mainWindowBounds; + } + if (settings.mainWindowMaximized) { + document.mainWindowMaximized = true; + } if (settings.serverExposureMode !== defaults.serverExposureMode) { document.serverExposureMode = settings.serverExposureMode; } @@ -237,6 +284,22 @@ function setServerExposureMode( }; } +function setMainWindowBounds( + settings: DesktopSettings, + bounds: DesktopWindowBounds, + isMaximized: boolean, +): DesktopSettings { + return settings.mainWindowBounds !== null && + desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) && + settings.mainWindowMaximized === isMaximized + ? settings + : { + ...settings, + mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, + }; +} + function setTailscaleServe( settings: DesktopSettings, input: { readonly enabled: boolean; readonly port: Option.Option }, @@ -431,6 +494,18 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), + setMainWindowBounds: (bounds, isMaximized) => + persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( + Effect.withSpan("desktop.settings.setMainWindowBounds", { + attributes: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized, + }, + }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -488,6 +563,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), + setMainWindowBounds: (bounds, isMaximized) => + update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 5ae92bbee96..32224c7a5ca 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -158,6 +158,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index ba77292fdc8..168846466ed 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -76,6 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 492ef1ad577..587da8d4431 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1,12 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -18,12 +23,20 @@ vi.mock("electron", async (importOriginal) => ({ setUserAgent: vi.fn(), })), }, + screen: { + getAllDisplays: vi.fn(() => [ + { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + }, + ]), + }, })); import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -66,15 +79,21 @@ function makeFakeBrowserWindow() { const window = { close: vi.fn(), focus: vi.fn(), + getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), + getNormalBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), isDestroyed: vi.fn(() => false), isFullScreen: vi.fn(() => false), + isMaximized: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), + maximize: vi.fn(), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { windowListeners.set(eventName, listener); }), - once: vi.fn(), + once: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), restore: vi.fn(), setBackgroundColor: vi.fn(), setAutoHideCursor: vi.fn(), @@ -86,7 +105,14 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, + getBounds: window.getBounds, + getNormalBounds: window.getNormalBounds, + isDestroyed: window.isDestroyed, + isFullScreen: window.isFullScreen, + isMaximized: window.isMaximized, + isMinimized: window.isMinimized, loadURL: window.loadURL, + maximize: window.maximize, openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, @@ -144,13 +170,57 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( ), ); +const desktopWindowBoundsEquivalence = Schema.toEquivalence( + DesktopAppSettings.DesktopWindowBoundsSchema, +); + function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; + readonly desktopSettings?: DesktopAppSettings.DesktopSettings; + readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; + readonly mainWindowMaximizedUpdates?: boolean[]; + readonly beforeMainWindowBoundsUpdate?: ( + bounds: DesktopAppSettings.DesktopWindowBounds, + ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; }) { + let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; + const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.sync(() => desktopSettings), + load: Effect.sync(() => desktopSettings), + setMainWindowBounds: (bounds, isMaximized) => + Effect.gen(function* () { + if (input.beforeMainWindowBoundsUpdate) { + yield* input.beforeMainWindowBoundsUpdate(bounds); + } + const changed = + desktopSettings.mainWindowBounds === null || + !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds) || + desktopSettings.mainWindowMaximized !== isMaximized; + if (changed) { + desktopSettings = { + ...desktopSettings, + mainWindowBounds: bounds, + mainWindowMaximized: isMaximized, + }; + input.mainWindowBoundsUpdates?.push(bounds); + input.mainWindowMaximizedUpdates?.push(isMaximized); + } + return { settings: desktopSettings, changed }; + }), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: (options) => Effect.sync(() => { @@ -175,6 +245,7 @@ function makeTestLayer(input: { Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + desktopAppSettingsLayer, desktopServerExposureLayer, DesktopState.layer, electronMenuLayer, @@ -272,6 +343,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + DesktopAppSettings.layerTest(), desktopServerExposureLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { @@ -294,6 +366,23 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it("restores bounds only when the window fits within a connected display", () => { + const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; + const displays = [ + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 1920, y: 0, width: 2560, height: 1440 }, + ]; + + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, displays), + persistedBounds, + ); + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, [displays[0]!]), + DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE, + ); + }); + it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ @@ -335,6 +424,10 @@ describe("DesktopWindow", () => { yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); assert.equal(yield* Ref.get(createCount), 1); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); @@ -343,6 +436,427 @@ describe("DesktopWindow", () => { }), ); + it.effect("uses the persisted main window bounds when opening the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(createdWindowOptions[0]?.width, 1320); + assert.equal(createdWindowOptions[0]?.height, 880); + assert.equal(createdWindowOptions[0]?.x, 120); + assert.equal(createdWindowOptions[0]?.y, 80); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("restores the persisted maximized state", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + mainWindowMaximized: true, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(fakeWindow.maximize.mock.calls.length, 0); + const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); + if (!readyToShow) { + return yield* Effect.die("window ready-to-show listener was not registered"); + } + readyToShow(); + assert.equal(fakeWindow.maximize.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("debounces move and resize bounds updates", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const move = fakeWindow.windowListeners.get("move"); + const resize = fakeWindow.windowListeners.get("resize"); + if (!move || !resize) { + return yield* Effect.die("window bounds listeners were not registered"); + } + + fakeWindow.getBounds.mockReturnValue({ x: 120, y: 80, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(250); + + fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 }); + resize(); + yield* TestClock.adjust(499); + assert.deepEqual(mainWindowBoundsUpdates, []); + + yield* TestClock.adjust(1); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists normal bounds and state for a maximized window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists normal bounds and state from the native maximize event", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const maximize = fakeWindow.windowListeners.get("maximize"); + if (!maximize) { + return yield* Effect.die("window maximize listener was not registered"); + } + + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + maximize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("does not persist bounds that fail the domain schema", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 100.4, y: 80.2, width: 839.4, height: 619.4 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, []); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("preserves unrestorable bounds until the user changes the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 2040, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + const move = fakeWindow.windowListeners.get("move"); + if (!close || !move) { + return yield* Effect.die("window lifecycle listeners were not registered"); + } + + close(); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, []); + + fakeWindow.getBounds.mockReturnValue({ x: 80, y: 60, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 80, y: 60, width: 1280, height: 840 }]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("flushes normal bounds when fullscreen before the debounce completes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 200, y: 130, width: 1400, height: 940 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(250); + fakeWindow.isFullScreen.mockReturnValue(true); + + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("flushes normal bounds when minimized before the debounce completes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 180, y: 120, width: 1440, height: 960 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); + } + resize(); + yield* TestClock.adjust(250); + fakeWindow.isMinimized.mockReturnValue(true); + + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 180, y: 120, width: 1440, height: 960 }]); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("logs display lookup failures before falling back to the default size", () => + Effect.gen(function* () { + const displayLookupFailure = new Error("screen API unavailable"); + vi.mocked(Electron.screen.getAllDisplays).mockImplementationOnce(() => { + throw displayLookupFailure; + }); + const logRecords: Array<{ + readonly message: unknown; + readonly annotations: Readonly>; + }> = []; + const logger = Logger.make(({ fiber, message }) => { + logRecords.push({ + message, + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + }).pipe( + Effect.provide(Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false }))), + ); + + const warning = logRecords.find( + (record) => + Array.isArray(record.message) && + record.message[0] === "failed to read connected displays; using defaults", + ); + assert.isDefined(warning); + assert.strictEqual(warning.annotations.cause, displayLookupFailure); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); + }), + ); + + it.effect("persists the current main window bounds before the window closes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 240, y: 160, width: 1410, height: 930 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const writeStarted = yield* Deferred.make(); + const allowWrite = yield* Deferred.make(); + const flushCompleted = yield* Deferred.make(); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + beforeMainWindowBoundsUpdate: () => + Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowWrite)), + Effect.asVoid, + ), + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Deferred.await(writeStarted); + fakeWindow.isDestroyed.mockReturnValue(true); + + const flushFiber = yield* desktopWindow.flushMainWindowBounds.pipe( + Effect.andThen(Deferred.succeed(flushCompleted, undefined)), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + assert.isFalse(yield* Deferred.isDone(flushCompleted)); + + yield* Deferred.succeed(allowWrite, undefined); + yield* Fiber.join(flushFiber); + assert.isTrue(yield* Deferred.isDone(flushCompleted)); + + assert.deepEqual(mainWindowBoundsUpdates, [ + { + x: 240, + y: 160, + width: 1410, + height: 930, + }, + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("publishes native macOS fullscreen changes to the renderer", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index c808c77e51c..db4b698434d 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -17,11 +17,13 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED @@ -41,6 +43,7 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets + | DesktopAppSettings.DesktopAppSettings | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -75,6 +78,7 @@ export class DesktopWindow extends Context.Service< // window so a "macOS dock click" while the backend is down doesn't // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; + readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -99,6 +103,45 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } +type DisplayBounds = Pick; + +function windowFitsWithinDisplay( + windowBounds: DesktopAppSettings.DesktopWindowBounds, + displayBounds: DisplayBounds, +): boolean { + return ( + windowBounds.x >= displayBounds.x && + windowBounds.y >= displayBounds.y && + windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width && + windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height + ); +} + +function windowBoundsEqual( + left: DesktopAppSettings.DesktopWindowBounds, + right: DesktopAppSettings.DesktopWindowBounds, +): boolean { + return ( + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height + ); +} + +export function resolveInitialMainWindowBounds( + persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, + displays: readonly DisplayBounds[], +): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE { + if ( + persistedBounds !== null && + displays.some((display) => windowFitsWithinDisplay(persistedBounds, display)) + ) { + return persistedBounds; + } + return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; +} + // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. @@ -202,6 +245,7 @@ export const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -214,6 +258,7 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); @@ -250,9 +295,31 @@ export const make = Effect.gen(function* () { const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const persistedSettings = yield* desktopSettings.get; + const persistedBounds = persistedSettings.mainWindowBounds; + const displayBoundsResult = yield* Effect.sync(() => { + try { + return { + _tag: "Success" as const, + bounds: Electron.screen.getAllDisplays().map((display) => display.bounds), + }; + } catch (cause) { + return { _tag: "Failure" as const, cause }; + } + }); + const displayBounds = + displayBoundsResult._tag === "Success" + ? displayBoundsResult.bounds + : yield* logWindowWarning("failed to read connected displays; using defaults", { + cause: displayBoundsResult.cause, + }).pipe(Effect.as([])); + const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); + const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; + if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { + yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); + } const window = yield* electronWindow.create({ - width: 1100, - height: 780, + ...initialBounds, minWidth: 840, minHeight: 620, show: false, @@ -274,6 +341,92 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } + let boundsPersistFiber: Fiber.Fiber | undefined; + let pendingBoundsPersistFiber: Fiber.Fiber | undefined; + let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; + const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { + if (window.isDestroyed()) { + return null; + } + const bounds = + window.isFullScreen() || window.isMaximized() || window.isMinimized() + ? window.getNormalBounds() + : window.getBounds(); + return DesktopAppSettings.normalizeMainWindowBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.round(bounds.width), + height: Math.round(bounds.height), + }); + }; + const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); + const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; + const persistCurrentBounds = (): Fiber.Fiber | undefined => { + if (!boundsPersistenceEnabled) { + return pendingBoundsPersistFiber; + } + const bounds = readPersistableBounds(); + if (bounds === null) { + return pendingBoundsPersistFiber; + } + pendingBoundsPersistFiber = runFork( + desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe( + Effect.asVoid, + Effect.catch((error) => + logWindowWarning("failed to persist main window bounds", { + message: error.message, + }), + ), + ), + ); + return pendingBoundsPersistFiber; + }; + const scheduleBoundsPersist = () => { + if (!boundsPersistenceEnabled) { + const currentBounds = readPersistableBounds(); + if ( + currentBounds === null || + (fallbackWindowBounds !== null && + windowBoundsEqual(currentBounds, fallbackWindowBounds) && + window.isMaximized() === fallbackWindowMaximized) + ) { + return; + } + } + boundsPersistenceEnabled = true; + if (boundsPersistFiber !== undefined) { + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + } + boundsPersistFiber = runFork( + Effect.sleep(MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS).pipe( + Effect.andThen( + Effect.sync(() => { + boundsPersistFiber = undefined; + void persistCurrentBounds(); + }), + ), + ), + ); + }; + const clearBoundsPersist = () => { + if (boundsPersistFiber === undefined) { + return; + } + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + }; + const flushBoundsPersist = Effect.sync(() => { + clearBoundsPersist(); + return persistCurrentBounds(); + }).pipe( + Effect.flatMap((fiber) => + fiber === undefined ? Effect.void : Fiber.join(fiber).pipe(Effect.asVoid), + ), + ); + flushMainWindowBounds = flushBoundsPersist; yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { @@ -364,6 +517,13 @@ export const make = Effect.gen(function* () { event.preventDefault(); window.setTitle(environment.displayName); }); + window.on("resize", scheduleBoundsPersist); + window.on("move", scheduleBoundsPersist); + window.on("maximize", scheduleBoundsPersist); + window.on("unmaximize", scheduleBoundsPersist); + window.on("close", () => { + runFork(flushBoundsPersist); + }); if (environment.platform === "darwin") { window.on("enter-full-screen", () => { @@ -472,6 +632,9 @@ export const make = Effect.gen(function* () { bindFirstRevealTrigger(revealSubscribers, () => { // Reveal the real window, then close the connecting splash (if any) so the // two don't overlap and there's no blank gap between them. + if (persistedSettings.mainWindowMaximized) { + window.maximize(); + } void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); }); @@ -482,6 +645,7 @@ export const make = Effect.gen(function* () { window.on("closed", () => { clearDevelopmentLoadRetry(); + clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); }); @@ -598,6 +762,9 @@ export const make = Effect.gen(function* () { handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), ), + flushMainWindowBounds: Effect.suspend(() => flushMainWindowBounds).pipe( + Effect.withSpan("desktop.window.flushMainWindowBounds"), + ), dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); const existingWindow = yield* focusedMainWindow; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index ee56858bc58..6c692dc3de8 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,13 +1,22 @@ import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; +import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; +import { + resolveInitialThreadSidebarWidth, + resolveThreadSidebarMaximumWidth, + THREAD_MAIN_CONTENT_MIN_WIDTH, + THREAD_SIDEBAR_MIN_WIDTH, + THREAD_SIDEBAR_WIDTH_STORAGE_KEY, +} from "./threadSidebarWidth"; import { Sidebar, SidebarProvider, @@ -18,11 +27,20 @@ import { } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; -const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; -const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; +function readInitialThreadSidebarWidth(): number { + try { + return resolveInitialThreadSidebarWidth( + getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite), + window.innerWidth, + ); + } catch (error) { + console.error("Could not read persisted thread sidebar width.", error); + return resolveInitialThreadSidebarWidth(null, window.innerWidth); + } +} + function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); @@ -82,16 +100,20 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" ? getWindowFullscreenState() : false; }); - const macosWindowControlsStyle = - isMacosDesktop && !isWindowFullscreen - ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) - : undefined; + const sidebarProviderStyle = { + "--sidebar-width": `${sidebarWidth}px`, + ...(isMacosDesktop && !isWindowFullscreen + ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } + : {}), + } as CSSProperties; useEffect(() => { if (!isMacosDesktop) return; @@ -131,17 +153,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate, pathname]); return ( - + nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + onResize: setSidebarWidth, }} > diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts new file mode 100644 index 00000000000..12aef63bd5e --- /dev/null +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveInitialThreadSidebarWidth, + THREAD_MAIN_CONTENT_MIN_WIDTH, + THREAD_SIDEBAR_DEFAULT_WIDTH, + THREAD_SIDEBAR_MIN_WIDTH, +} from "./threadSidebarWidth"; + +describe("thread sidebar width", () => { + it("uses the default width when no preference is stored", () => { + expect(resolveInitialThreadSidebarWidth(null, 1200)).toBe(THREAD_SIDEBAR_DEFAULT_WIDTH); + }); + + it("uses a stored width in the initial render", () => { + expect(resolveInitialThreadSidebarWidth(360, 1200)).toBe(360); + }); + + it("clamps a stored width to the sidebar minimum", () => { + expect(resolveInitialThreadSidebarWidth(120, 1200)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + }); + + it("leaves enough room for the main content on a smaller window", () => { + const viewportWidth = 1000; + + expect(resolveInitialThreadSidebarWidth(900, viewportWidth)).toBe( + viewportWidth - THREAD_MAIN_CONTENT_MIN_WIDTH, + ); + }); + + it("keeps the sidebar minimum when the whole layout is narrower than its minimums", () => { + expect(resolveInitialThreadSidebarWidth(900, 700)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + }); +}); diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts new file mode 100644 index 00000000000..93dd196e84d --- /dev/null +++ b/apps/web/src/components/threadSidebarWidth.ts @@ -0,0 +1,22 @@ +export const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; +export const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; +export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; +export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; + +export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number { + return Math.max( + THREAD_SIDEBAR_MIN_WIDTH, + Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH, + ); +} + +export function resolveInitialThreadSidebarWidth( + storedWidth: number | null, + viewportWidth: number, +): number { + const preferredWidth = + storedWidth === null + ? THREAD_SIDEBAR_DEFAULT_WIDTH + : Math.max(THREAD_SIDEBAR_MIN_WIDTH, storedWidth); + return Math.min(preferredWidth, resolveThreadSidebarMaximumWidth(viewportWidth)); +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 50fb652a495..51335d1a6f8 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -555,16 +555,24 @@ function SidebarRail({ [onClick, open, resolvedResizable, toggleSidebar], ); - React.useEffect(() => { + React.useLayoutEffect(() => { if (!resolvedResizable?.storageKey || typeof window === "undefined") return; const rail = railRef.current; if (!rail) return; const wrapper = rail.closest("[data-slot='sidebar-wrapper']"); if (!wrapper) return; - const storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); + let storedWidth: number | null; + try { + storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); + } catch (error) { + console.error("Could not restore persisted sidebar width.", error); + return; + } if (storedWidth === null) return; const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); + // Hydrate the CSS variable before the browser paints so a restored sidebar + // never flashes at the default width first. wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); resolvedResizable.onResize?.(clampedWidth); }, [resolvedResizable]); From db4b2d8a0f23b00f19774dda33e0b02627272c90 Mon Sep 17 00:00:00 2001 From: Rusiru Sadathana Date: Mon, 20 Jul 2026 18:14:58 +0530 Subject: [PATCH 028/110] perf(orchestration): speed up new-chat propagation and offline catch-up (#4177) Co-authored-by: codex Co-authored-by: Julius Marminge --- .../Layers/OrchestrationEngine.test.ts | 2 + .../Layers/OrchestrationEngine.ts | 5 + .../Services/OrchestrationEngine.ts | 7 + .../src/relay/AgentAwarenessRelay.test.ts | 2 + apps/server/src/server.test.ts | 470 +++++++++++++++++- apps/server/src/serverRuntimeStartup.test.ts | 3 + apps/server/src/ws.ts | 356 +++++++++---- 7 files changed, 749 insertions(+), 96 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 405103450e8..b00c00e0d3f 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -218,6 +218,7 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + expect(await runtime.runPromise(engine.latestSequence)).toBe(7); const result = await runtime.runPromise( engine.dispatch({ type: "thread.meta.update", @@ -228,6 +229,7 @@ describe("OrchestrationEngine", () => { ); expect(result.sequence).toBe(8); + expect(await runtime.runPromise(engine.latestSequence)).toBe(8); expect(fullSnapshotReadCount).toBe(0); await runtime.dispose(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 9598d1c3ef5..19184915ac7 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -329,6 +329,11 @@ const makeOrchestrationEngine = Effect.gen(function* () { get streamDomainEvents(): OrchestrationEngineShape["streamDomainEvents"] { return Stream.fromPubSub(eventPubSub); }, + // The command read model's snapshotSequence tracks the latest committed + // event sequence (updated on the worker fiber). A plain property read is a + // consistent, committed value — reassignment of `commandReadModel` is + // atomic on the single-threaded event loop. + latestSequence: Effect.sync(() => commandReadModel.snapshotSequence), } satisfies OrchestrationEngineShape; }); diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index dc1b5fd3003..f8bcfd76ac0 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -56,6 +56,13 @@ export interface OrchestrationEngineShape { * This is a hot runtime stream (new events only), not a historical replay. */ readonly streamDomainEvents: Stream.Stream; + + /** + * The latest sequence reflected in the engine's authoritative command read + * model (0 if none). Used to gauge how far behind a resuming client is before + * choosing between an incremental replay and a fresh projected snapshot. + */ + readonly latestSequence: Effect.Effect; } /** diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9ca17dbc2dd..0c261c8f21e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -470,6 +470,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineShape; const snapshotQuery = { @@ -659,6 +660,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineShape), Layer.succeed(ProjectionSnapshotQuery, { getShellSnapshot: () => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a0f41cbf0fc..6122c498145 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -46,6 +46,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; @@ -677,6 +678,7 @@ const buildAppUnderTest = (options?: { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 0 }), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), ...options?.layers?.orchestrationEngine, }), ), @@ -5688,7 +5690,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectionSnapshotQuery: { getShellSnapshot: () => Effect.gen(function* () { - yield* Effect.sleep("25 millis"); yield* PubSub.publish(liveEvents, deletedEvent); return { snapshotSequence: 1, @@ -5774,6 +5775,473 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + it.effect("subscribeShell sends a fresh snapshot instead of replaying a large gap", () => + Effect.gen(function* () { + let readEventsCalls = 0; + const snapshotThreadId = ThreadId.make("thread-from-snapshot"); + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + // Head is far ahead of the client's afterSequence (gap > 1000). + latestSequence: Effect.succeed(100_000), + readEvents: () => + Stream.sync(() => { + readEventsCalls += 1; + return { + sequence: 1, + eventId: EventId.make("event-should-not-be-read"), + aggregateKind: "thread", + aggregateId: snapshotThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + } satisfies OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 100_000, + projects: [], + threads: [makeDefaultOrchestrationThreadShell({ id: snapshotThreadId })], + updatedAt: now, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 5, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(items); + // Large gap => fresh snapshot, and the unbounded replay is never started. + assert.equal(first?.kind, "snapshot"); + if (first?.kind === "snapshot") { + assert.equal(first.snapshot.threads[0]?.id, snapshotThreadId); + } + assert.equal(second?.kind, "synchronized"); + assert.equal(readEventsCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell replaces a cursor ahead of the authoritative head", () => + Effect.gen(function* () { + let readEventsCalls = 0; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(5), + readEvents: () => + Stream.sync(() => { + readEventsCalls += 1; + return {} as OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 5, + projects: [], + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const first = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 10 }).pipe( + Stream.runHead, + ), + ), + ); + + assert.equal(Option.getOrThrow(first).kind, "snapshot"); + assert.equal(readEventsCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalesces a per-thread burst without stalling other threads", () => + Effect.gen(function* () { + const busyThreadId = ThreadId.make("thread-busy"); + const newThreadId = ThreadId.make("thread-new"); + const now = "2026-01-01T00:00:00.000Z"; + const shellFetches: Array = []; + let replayLimit: number | undefined; + + const messageEvent = (sequence: number): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: busyThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }) satisfies OrchestrationEvent; + + const createdEvent: OrchestrationEvent = { + sequence: 50, + eventId: EventId.make("event-created"), + aggregateKind: "thread", + aggregateId: newThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(50), + // A burst of message-sent deltas for the busy thread, plus one + // thread.created for a different thread, all within one batch. + readEvents: (_afterSequence, limit) => { + replayLimit = limit; + return Stream.fromIterable([ + ...Array.from({ length: 20 }, (_unused, index) => messageEvent(index + 1)), + createdEvent, + ]); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: (threadId) => + Effect.sync(() => { + shellFetches.push(threadId); + return Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(3), Stream.runCollect), + ), + ); + + const collected = Array.from(items); + const upsertedIds = collected.flatMap((item) => + item.kind === "thread-upserted" ? [item.thread.id] : [], + ); + // Both threads surface, and the busy thread's 20-event burst collapses to + // a single shell refetch (not 20). The new thread is not stuck behind it. + assert.include(upsertedIds, busyThreadId); + assert.include(upsertedIds, newThreadId); + assert.equal(collected[2]?.kind, "synchronized"); + assert.equal(shellFetches.filter((id) => id === busyThreadId).length, 1); + assert.equal(replayLimit, 50); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalesces live bursts after the synchronization marker", () => + Effect.gen(function* () { + const busyThreadId = ThreadId.make("thread-live-busy"); + const newThreadId = ThreadId.make("thread-live-new"); + const now = "2026-01-01T00:00:00.000Z"; + const liveEvents = yield* PubSub.unbounded(); + const synchronized = yield* Deferred.make(); + const shellFetches: Array = []; + const observedLiveThreadIds = new Set(); + + const messageEvent = (sequence: number): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-live-${sequence}`), + aggregateKind: "thread", + aggregateId: busyThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }) satisfies OrchestrationEvent; + + const createdEvent: OrchestrationEvent = { + sequence: 50, + eventId: EventId.make("event-live-created"), + aggregateKind: "thread", + aggregateId: newThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.created", + payload: {} as never, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadShellById: (threadId) => + Effect.sync(() => { + shellFetches.push(threadId); + return Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + Effect.gen(function* () { + const itemsFiber = yield* withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + requestCompletionMarker: true, + }).pipe( + Stream.tap((item) => + item.kind === "synchronized" + ? Deferred.succeed(synchronized, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Stream.takeUntil((item) => { + if (item.kind === "thread-upserted") { + observedLiveThreadIds.add(item.thread.id); + } + return ( + observedLiveThreadIds.has(busyThreadId) && observedLiveThreadIds.has(newThreadId) + ); + }), + Stream.runCollect, + ), + ).pipe(Effect.forkScoped); + + yield* Deferred.await(synchronized); + for (const event of [ + ...Array.from({ length: 20 }, (_unused, index) => messageEvent(index + 1)), + createdEvent, + ]) { + yield* PubSub.publish(liveEvents, event); + } + + return yield* Fiber.join(itemsFiber); + }), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "synchronized"); + const liveUpsertedIds = Array.from(items) + .slice(2) + .flatMap((item) => (item.kind === "thread-upserted" ? [item.thread.id] : [])); + assert.include(liveUpsertedIds, busyThreadId); + assert.include(liveUpsertedIds, newThreadId); + assert.isBelow(shellFetches.filter((id) => id === busyThreadId).length, 20); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("subscribeShell coalescing still emits a removal for a deleted thread", () => + Effect.gen(function* () { + const goneThreadId = ThreadId.make("thread-gone"); + const now = "2026-01-01T00:00:00.000Z"; + + const makeThreadEvent = ( + sequence: number, + type: "thread.deleted" | "thread.message-sent", + ): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: goneThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type, + payload: type === "thread.deleted" ? { threadId: goneThreadId, deletedAt: now } : {}, + }) as OrchestrationEvent; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(2), + // A thread.deleted followed, within the same coalescing window, by a + // later refetchable event for the same thread. The later event wins + // coalescing; its shell refetch returns none (the row is gone), which + // must still surface a removal rather than be swallowed. + readEvents: () => + Stream.fromIterable([ + makeThreadEvent(1, "thread.deleted"), + makeThreadEvent(2, "thread.message-sent"), + ]), + }, + projectionSnapshotQuery: { + getThreadShellById: () => Effect.succeed(Option.none()), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const [first] = Array.from(items); + assert.equal(first?.kind, "thread-removed"); + assert.equal(first?.kind === "thread-removed" ? first.threadId : null, goneThreadId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell retries a transient shell projection refetch failure", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-transient-refetch"); + const now = "2026-01-01T00:00:00.000Z"; + let attempts = 0; + + const event: OrchestrationEvent = { + sequence: 1, + eventId: EventId.make("event-transient-refetch"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: {} as never, + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(1), + readEvents: () => Stream.make(event), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.suspend(() => { + attempts += 1; + return attempts === 1 + ? Effect.fail( + new PersistenceSqlError({ + operation: "test.shell-refetch", + detail: "transient failure", + }), + ) + : Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })), + ); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const [first] = Array.from(items); + assert.equal(first?.kind, "thread-upserted"); + assert.equal(first?.kind === "thread-upserted" ? first.thread.id : null, threadId); + assert.equal(attempts, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeShell coalescing still removes a project after a trailing update", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project-gone"); + const now = "2026-01-01T00:00:00.000Z"; + + const makeProjectEvent = ( + sequence: number, + type: "project.deleted" | "project.meta-updated", + ): OrchestrationEvent => + ({ + sequence, + eventId: EventId.make(`event-project-${sequence}`), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type, + payload: + type === "project.deleted" + ? { projectId, deletedAt: now } + : { projectId, title: "Still deleted", updatedAt: now }, + }) as OrchestrationEvent; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(2), + readEvents: () => + Stream.fromIterable([ + makeProjectEvent(1, "project.deleted"), + makeProjectEvent(2, "project.meta-updated"), + ]), + }, + projectionSnapshotQuery: { + getProjectShellById: () => Effect.succeed(Option.none()), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ afterSequence: 0 }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const [first] = Array.from(items); + assert.equal(first?.kind, "project-removed"); + assert.equal(first?.kind === "project-removed" ? first.projectId : null, projectId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("enriches replayed project events with repository identity metadata", () => Effect.gen(function* () { const repositoryIdentity = { diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 1a331bc717f..b8102bda9ad 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -168,6 +168,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -211,6 +212,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), ); @@ -260,6 +262,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provideService(Crypto.Crypto, { ...crypto, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 41c29fc3388..08b1770a0a2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -36,6 +36,7 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + type ProjectId, type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, @@ -276,6 +277,13 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< const PROVIDER_STATUS_DEBOUNCE_MS = 200; +// When a resuming client's cursor is more than this many events behind the +// current head, skip the per-event catch-up replay and send a fresh shell +// snapshot instead. Replaying each intervening event costs a shell refetch; +// past this gap a single O(active-threads) snapshot is cheaper and bounded. +// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). +const SHELL_RESUME_MAX_GAP = 1_000; + const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], @@ -621,16 +629,7 @@ const makeWsRpcLayer = ( switch (event.type) { case "project.created": case "project.meta-updated": - return projectionSnapshotQuery.getProjectShellById(event.payload.projectId).pipe( - Effect.map((project) => - Option.map(project, (nextProject) => ({ - kind: "project-upserted" as const, - sequence: event.sequence, - project: nextProject, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + return projectUpsertOrRemove(event.payload.projectId, event.sequence); case "project.deleted": return Effect.succeed( Option.some({ @@ -649,35 +648,192 @@ const makeWsRpcLayer = ( }), ); case "thread.unarchived": - return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + return threadUpsertOrRemove(event.payload.threadId, event.sequence); default: if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); } - return projectionSnapshotQuery - .getThreadShellById(ThreadId.make(event.aggregateId)) - .pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); } }; + // Coalescing makes each projection read represent every event for that + // aggregate in the current window. Retry a typed persistence failure once + // so a brief read failure cannot strand the shell at its previous state. + // If both attempts fail, log and drop the stream item; treating an error as + // a missing row would incorrectly remove a still-active aggregate. + const retryShellProjectionRead = ( + aggregateKind: "project" | "thread", + aggregateId: string, + read: Effect.Effect, + ): Effect.Effect, never, never> => + read.pipe( + Effect.retry({ times: 1 }), + Effect.map(Option.some), + Effect.tapError((error) => + Effect.logWarning("orchestration shell projection refetch failed", { + aggregateKind, + aggregateId, + error, + }), + ), + Effect.orElseSucceed(() => Option.none()), + ); + + const projectUpsertOrRemove = ( + projectId: ProjectId, + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "project", + projectId, + projectionSnapshotQuery.getProjectShellById(projectId), + ).pipe( + Effect.map( + Option.flatMap((project) => + Option.match(project, { + onNone: () => + Option.some({ + kind: "project-removed" as const, + sequence, + projectId, + }), + onSome: (nextProject) => + Option.some({ + kind: "project-upserted" as const, + sequence, + project: nextProject, + }), + }), + ), + ), + ); + + // Refetch a thread's shell and emit an upsert if it is still active, or a + // `thread-removed` if the projection has no active row for it. Emitting a + // removal on a `none` (rather than dropping the event) is what keeps + // coalescing correct: when a burst collapses a `thread.deleted`/`archived` + // into a later refetchable event for the same thread, the refetch returns + // `none` for the now-inactive row and this still tells the sidebar to drop + // it. A `thread-removed` the client does not have is a harmless no-op. The + // projection commits in the same transaction before the event publishes, + // so a `none` reliably means the thread is deleted or archived, not + // not-yet-persisted. + const threadUpsertOrRemove = ( + threadId: ThreadId, + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "thread", + threadId, + projectionSnapshotQuery.getThreadShellById(threadId), + ).pipe( + Effect.map( + Option.flatMap((thread) => + Option.match(thread, { + onNone: () => + Option.some({ + kind: "thread-removed" as const, + sequence, + threadId, + }), + onSome: (nextThread) => + Option.some({ + kind: "thread-upserted" as const, + sequence, + thread: nextThread, + }), + }), + ), + ), + ); + + // Turn a batch of domain events into shell stream items, coalescing by + // aggregate first. `toShellStreamEvent` re-reads the *current* projected + // shell for an aggregate, so within a batch only the latest event per + // aggregate matters: a burst of streaming `thread.message-sent` deltas for + // one thread collapses into a single shell refetch, and an unrelated + // `thread.created` in the same batch is never stuck behind those DB reads. + // + // Input events arrive in ascending sequence; we keep the last (highest + // sequence) event per aggregate, then re-sort ascending before emitting so + // the client — which applies shell items strictly by increasing sequence + // and drops any `sequence <= snapshotSequence` — never skips a coalesced + // item. The refetch runs with bounded concurrency (order-preserving). + const SHELL_REFETCH_CONCURRENCY = 8; + const coalesceShellEvents = ( + events: ReadonlyArray, + ): Effect.Effect, never, never> => + Effect.gen(function* () { + if (events.length === 0) { + return []; + } + const latestByAggregate = new Map(); + for (const event of events) { + latestByAggregate.set(`${event.aggregateKind}:${event.aggregateId}`, event); + } + const survivors = Array.from(latestByAggregate.values()).sort( + (left, right) => left.sequence - right.sequence, + ); + const shellEvents = yield* Effect.forEach(survivors, toShellStreamEvent, { + concurrency: SHELL_REFETCH_CONCURRENCY, + }); + return shellEvents.flatMap((option) => (Option.isSome(option) ? [option.value] : [])); + }); + + // Small time/size window over which to coalesce shell events. The window + // bounds the worst-case added latency for a brand-new thread to appear in + // the sidebar (imperceptible), while collapsing high-frequency streaming + // traffic so it can't serialize the shell stream behind per-event DB reads. + const SHELL_COALESCE_WINDOW = Duration.millis(50); + const SHELL_COALESCE_MAX_CHUNK = 512; + const coalesceShellStream = ( + stream: Stream.Stream, + ): Stream.Stream => + stream.pipe( + Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), + Stream.mapEffect(coalesceShellEvents), + Stream.flatMap((items) => Stream.fromIterable(items)), + ); + + type ShellLiveInput = + | { readonly kind: "event"; readonly event: OrchestrationEvent } + | { readonly kind: "synchronized" }; + + // A completion marker is queued alongside raw live events so it cannot + // overtake an event still waiting in the coalescing window. Split each + // batch at markers and coalesce only the event segments on either side. + const coalesceShellLiveInputs = ( + inputs: ReadonlyArray, + ): Effect.Effect, never, never> => + Effect.gen(function* () { + const output: Array = []; + let pendingEvents: Array = []; + + for (const input of inputs) { + if (input.kind === "event") { + pendingEvents.push(input.event); + continue; + } + + output.push(...(yield* coalesceShellEvents(pendingEvents))); + pendingEvents = []; + output.push({ kind: "synchronized" }); + } + + output.push(...(yield* coalesceShellEvents(pendingEvents))); + return output; + }); + + const coalesceShellLiveStream = ( + stream: Stream.Stream, + ): Stream.Stream => + stream.pipe( + Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), + Stream.mapEffect(coalesceShellLiveInputs), + Stream.flatMap((items) => Stream.fromIterable(items)), + ); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -1068,68 +1224,29 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeShell, Effect.gen(function* () { - const liveStream = orchestrationEngine.streamDomainEvents.pipe( - Stream.mapEffect(toShellStreamEvent), - Stream.flatMap((event) => - Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, + // Coalesce the live shell stream per aggregate over a small window + // so bursts of high-frequency events (streaming message deltas, + // activity appends) collapse into a single shell refetch and never + // serialize a brand-new thread's `thread.created` behind hundreds + // of per-event DB reads. See coalesceShellStream. + // Attach live delivery into a scope-bound buffer BEFORE loading any + // snapshot or draining catch-up, otherwise an event published while + // the snapshot query is in flight is lost (it is past the snapshot's + // sequence but the live subscription is not attached yet). Every + // path below emits from this same buffered live tail. Overlapping + // events are deduped by sequence on the client. + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + orchestrationEngine.streamDomainEvents.pipe( + Stream.runForEach((event) => + Queue.offer(liveBuffer, { kind: "event" as const, event }), + ), ), + { startImmediately: true }, ); + const bufferedLiveStream = coalesceShellLiveStream(Stream.fromQueue(liveBuffer)); - // When the client already holds a shell snapshot (cached, or loaded - // over HTTP) it passes that snapshot's sequence, and we resume by - // replaying shell events after it instead of re-sending the whole - // projects/threads list over the socket. As in the thread path, the - // live subscription is attached (into a scope-bound buffer) before - // draining the catch-up replay so no event published during the - // replay window is lost; overlapping events are deduped by sequence - // on the client. The full range is read (not the store's default - // page limit) since the shell filter runs after reading. - if (input.afterSequence !== undefined) { - const afterSequence = input.afterSequence; - return Stream.unwrap( - Effect.gen(function* () { - const liveBuffer = yield* Queue.unbounded(); - yield* Effect.forkScoped( - liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), - ); - const catchUpStream = orchestrationEngine - .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) - .pipe( - Stream.mapEffect(toShellStreamEvent), - Stream.flatMap((event) => - Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, - ), - Stream.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to replay orchestration shell events", - cause, - }), - ), - ); - const afterCatchUp = - input.requestCompletionMarker === true - ? Stream.concat( - Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), - Stream.fromQueue(liveBuffer), - ) - : Stream.fromQueue(liveBuffer); - return Stream.concat(catchUpStream, afterCatchUp); - }), - ); - } - - // The full-snapshot fallback needs the same replay-window safety - // as the resume path: subscribe before loading the projection so - // events published while the snapshot is read are buffered. - const liveBuffer = yield* Queue.unbounded(); - yield* Effect.forkScoped( - liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), - ); - const bufferedLiveStream = Stream.fromQueue(liveBuffer); - const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( + const loadSnapshot = projectionSnapshotQuery.getShellSnapshot().pipe( Effect.tapError((cause) => Effect.logError("orchestration shell snapshot load failed", { cause }), ), @@ -1142,21 +1259,70 @@ const makeWsRpcLayer = ( ), ); - const afterSnapshot = + // Offer the completion marker into the same queue as live events. + // Anything buffered while snapshot/replay work was in flight is + // therefore delivered before the client is told it is synchronized. + const synchronizedThenLive = input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + Queue.offer(liveBuffer, { kind: "synchronized" as const }).pipe( + Effect.andThen(Queue.takeAll(liveBuffer)), + Effect.flatMap(coalesceShellLiveInputs), + ), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; + + // When the client already holds a shell snapshot (cached, or loaded + // over HTTP) it passes that snapshot's sequence, and we resume by + // replaying shell events after it instead of re-sending the whole + // projects/threads list over the socket. If the client is too far + // behind, we fall back to a fresh snapshot instead of an unbounded + // replay (see below). + if (input.afterSequence !== undefined) { + const afterSequence = input.afterSequence; + const headSequence = yield* orchestrationEngine.latestSequence; + const replayGap = headSequence - afterSequence; + // Gap too large: replaying every intervening event (each a shell + // refetch) is far more expensive than a single O(active-threads) + // snapshot. A cursor ahead of this engine's authoritative state + // is also invalid, so reset it with a snapshot. Send the snapshot + // followed by the buffered live tail, exactly as the + // no-afterSequence path does. + if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) { + const snapshot = yield* loadSnapshot; + return Stream.concat( + Stream.make({ kind: "snapshot" as const, snapshot }), + synchronizedThenLive, + ); + } + const catchUpStream = coalesceShellStream( + // Replay only through the head captured above. Newer events + // are already covered by the live subscription, so this bound + // cannot chase a moving event-store head or grow the live + // buffer indefinitely while waiting for an empty page. + orchestrationEngine.readEvents(afterSequence, replayGap), + ).pipe( + Stream.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to replay orchestration shell events", + cause, + }), + ), + ); + return Stream.concat(catchUpStream, synchronizedThenLive); + } + + const snapshot = yield* loadSnapshot; return Stream.concat( Stream.make({ kind: "snapshot" as const, snapshot, }), - afterSnapshot, + synchronizedThenLive, ); }), { "rpc.aggregate": "orchestration" }, From c8a04bd59b7e1a18124468f1592b4c925839a9de Mon Sep 17 00:00:00 2001 From: ss <69873514+sandersonstabo@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:45:36 +0200 Subject: [PATCH 029/110] Finale: upgrade changed files card to fix various UI issues (#4113) Co-authored-by: Julius Marminge --- .../components/chat/ChangedFilesTree.test.tsx | 27 +++++- .../src/components/chat/ChangedFilesTree.tsx | 96 ++++++++++++------- .../components/chat/MessagesTimeline.test.tsx | 54 ++++++++++- .../src/components/chat/MessagesTimeline.tsx | 84 +++++++++++----- 4 files changed, 201 insertions(+), 60 deletions(-) diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index c371acdb362..120827b34b2 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -2,7 +2,32 @@ import { TurnId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { ChangedFilesTree } from "./ChangedFilesTree"; +import { ChangedFilesCard, ChangedFilesTree } from "./ChangedFilesTree"; + +describe("ChangedFilesCard", () => { + it("keeps its compact header sticky while preserving singular labels", () => { + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + />, + ); + + expect(markup).toContain('class="sticky top-0 z-10'); + expect(markup).not.toContain("self-start"); + expect(markup).toContain("whitespace-nowrap"); + expect(markup).toContain("!size-[22px]"); + expect(markup).toContain("size-3"); + expect(markup).toContain('aria-label="Collapse all"'); + expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain("1 changed file"); + expect(markup).not.toContain("1 changed files"); + }); +}); describe("ChangedFilesTree", () => { it.each([ diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 3ea0e6315fd..6fc1462c5e6 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -6,11 +6,19 @@ import { summarizeTurnDiffStats, type TurnDiffTreeNode, } from "../../lib/turnDiffTree"; -import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; +import { + ChevronsDownUpIcon, + ChevronsUpDownIcon, + ChevronRightIcon, + FileDiffIcon, + FolderIcon, + FolderClosedIcon, +} from "lucide-react"; import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -33,10 +41,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); return ( -
-
-

- {files.length} changed files +

+
+

+ + {files.length} changed file{files.length === 1 ? "" : "s"} + {hasNonZeroStat(summaryStat) && (

- - + + + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all" : "Expand all"} + + + + onOpenTurnDiff(turnId, files[0]?.path)} + /> + } + > + + + View diff +
-
- -
+
); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a58724f3308..b340a248fbe 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId, MessageId } from "@t3tools/contracts"; +import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; import { createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; @@ -219,6 +219,58 @@ function buildUserTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("keeps assistant changed-files headers sticky below the thread header", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const assistantMessageId = MessageId.make("message-assistant-with-files"); + const turnId = TurnId.make("turn-with-files"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('class="sticky top-2 z-10'); + expect(markup).not.toContain("self-start"); + expect(markup).toContain("whitespace-nowrap"); + expect(markup).toContain("!size-[22px]"); + expect(markup).toContain("size-3"); + expect(markup).toContain('aria-label="Collapse all"'); + expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain("1 changed file"); + }); + it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { const { resolveTimelineIsAtEnd, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 73fc86312e5..f759aa150be 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -44,8 +44,11 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, + ChevronsDownUpIcon, + ChevronsUpDownIcon, CircleAlertIcon, EyeIcon, + FileDiffIcon, GlobeIcon, HammerIcon, MessageCircleIcon, @@ -1274,38 +1277,67 @@ function AssistantChangedFilesSectionInner({ ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const summaryStat = summarizeTurnDiffStats(checkpointFiles); - const changedFileCountLabel = String(checkpointFiles.length); return ( -
-
-

- Changed files ({changedFileCountLabel}) +

+
+

+ + {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"} + {hasNonZeroStat(summaryStat) && ( - <> - - - + )}

- - + + + setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) + } + /> + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all" : "Expand all"} + + + + onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)} + /> + } + > + + + View diff +
Date: Mon, 20 Jul 2026 16:22:35 +0200 Subject: [PATCH 030/110] Pass CLI OAuth config to hosted web deploy (#4186) Co-authored-by: codex --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12ac0370c3b..668d1fcb59d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -788,6 +788,7 @@ jobs: env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} @@ -871,6 +872,7 @@ jobs: --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ + --build-env "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=${T3CODE_CLERK_CLI_OAUTH_CLIENT_ID:-}" \ --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ From c0bb2373450231e3931937d2f703e35ce906ed85 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 18:27:57 -0700 Subject: [PATCH 031/110] fix(web): always show environment chip for remote projects (#4217) Co-authored-by: Claude Fable 5 --- .../components/BranchToolbar.logic.test.ts | 39 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 11 ++++++ apps/web/src/components/BranchToolbar.tsx | 18 +++++++-- .../BranchToolbarEnvironmentSelector.tsx | 6 ++- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 94a3909a961..8291e5e006e 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -12,6 +12,7 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, shouldIncludeBranchPickerItem, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -119,6 +120,44 @@ describe("resolveEnvironmentOptionLabel", () => { }); }); +describe("shouldShowEnvironmentIndicator", () => { + it("shows the indicator whenever multiple environments are pickable", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: true }, + canPickEnvironment: true, + }), + ).toBe(true); + }); + + it("shows a sole remote environment so the user knows where the project runs", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: false }, + canPickEnvironment: false, + }), + ).toBe(true); + }); + + it("hides a sole primary (this-device) environment", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: true }, + canPickEnvironment: false, + }), + ).toBe(false); + }); + + it("hides the indicator when the active environment is unknown", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: null, + canPickEnvironment: false, + }), + ).toBe(false); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c08..b16e1f590a9 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -42,6 +42,17 @@ export function resolveEnvironmentOptionLabel(input: { return runtimeLabel ?? savedLabel ?? input.environmentId; } +// A remote (non-primary) environment is always surfaced, even when it is the +// only environment available: with a single connected machine there is nothing +// to pick, but the user still needs to see where the project runs. +export function shouldShowEnvironmentIndicator(input: { + activeEnvironment: Pick | null; + canPickEnvironment: boolean; +}): boolean { + if (input.canPickEnvironment) return true; + return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 958cb3743c7..0354c2e0cd7 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -20,6 +20,7 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; @@ -60,6 +61,7 @@ interface MobileRunContextSelectorProps { environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[] | undefined; showEnvironmentPicker: boolean; + showEnvironmentIndicator: boolean; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; @@ -72,6 +74,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ environmentId, availableEnvironments, showEnvironmentPicker, + showEnvironmentIndicator, onEnvironmentChange, effectiveEnvMode, activeWorktreePath, @@ -94,7 +97,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ : resolveCurrentWorkspaceLabel(activeWorktreePath); const isLocked = envLocked || envModeLocked; const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; - const icon = showEnvironmentPicker ? ( + const icon = showEnvironmentIndicator ? ( // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. @@ -108,7 +111,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ <> {icon} - {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} ); @@ -234,6 +237,12 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); + const activeEnvironmentOption = + availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; + const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: showEnvironmentPicker, + }); const isMobile = useIsMobile(); if (!hasActiveThread || !activeProject) return null; @@ -247,6 +256,7 @@ export const BranchToolbar = memo(function BranchToolbar({ environmentId={environmentId} availableEnvironments={availableEnvironments} showEnvironmentPicker={showEnvironmentPicker} + showEnvironmentIndicator={showEnvironmentIndicator} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} @@ -254,13 +264,13 @@ export const BranchToolbar = memo(function BranchToolbar({ /> ) : (
- {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && ( + {showEnvironmentIndicator && availableEnvironments && ( <> diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 35fc8b6a190..dbc742bea5a 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -17,7 +17,9 @@ interface BranchToolbarEnvironmentSelectorProps { envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; - onEnvironmentChange: (environmentId: EnvironmentId) => void; + // Absent when there is only one environment to show: the indicator still + // renders (as a static label) so remote projects are always identifiable. + onEnvironmentChange?: (environmentId: EnvironmentId) => void; } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ @@ -39,7 +41,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir [availableEnvironments], ); - if (envLocked) { + if (envLocked || onEnvironmentChange === undefined) { return ( {activeEnvironment?.isPrimary ? ( From 23c18fda7a969634a30888e36b2da45f6d66a83b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 14:29:23 -0700 Subject: [PATCH 032/110] fix(web): keep composer editable while disconnected (#4241) Co-authored-by: Claude Fable 5 --- apps/web/src/components/chat/ChatComposer.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f3346b4100a..f35bff081e1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1159,6 +1159,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy || isConnecting || projectSelectionRequired || + environmentUnavailable !== null || !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = @@ -1697,7 +1698,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; - if (isSendBusy || isConnecting || phase === "running") return false; + if (isSendBusy || isConnecting || environmentUnavailable !== null || phase === "running") { + return false; + } if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } @@ -1706,6 +1709,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress, activePendingResolvedAnswers, composerSendState.hasSendableContent, + environmentUnavailable, isConnecting, isMobileViewport, isSendBusy, @@ -1892,8 +1896,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting || isComposerApprovalState || pendingUserInputs.length > 0 || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) + projectSelectionRequired ) { return false; } @@ -2101,8 +2104,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerApprovalState, pendingUserInputs.length, projectSelectionRequired, - environmentUnavailable, - activePendingProgress, applyPromptReplacement, isComposerModelPickerOpen, readComposerSnapshot, @@ -2144,7 +2145,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className={cn( "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-ring/45", isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border", - environmentUnavailable || projectSelectionRequired ? "opacity-75" : null, + projectSelectionRequired ? "opacity-75" : null, composerProviderState.composerSurfaceClassName, )} onFocusCapture={(event) => { @@ -2502,12 +2503,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" } - disabled={ - isConnecting || - isComposerApprovalState || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) - } + disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> {showMobilePendingAnswerActions ? (
Date: Tue, 21 Jul 2026 17:13:44 -0700 Subject: [PATCH 033/110] =?UTF-8?q?fix:=20better=20defaults=20=E2=80=94=20?= =?UTF-8?q?Claude=201M=20context,=20Codex=20gpt-5.6,=20worktrees=20from=20?= =?UTF-8?q?origin=20main=20(#4240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 --- .../threads/new-task-flow-provider.tsx | 39 +++++++++++----- apps/mobile/src/lib/modelOptions.ts | 3 ++ .../src/provider/Layers/ClaudeAdapter.test.ts | 11 ++--- .../src/provider/Layers/ClaudeAdapter.ts | 23 ++++------ .../src/provider/Layers/ClaudeProvider.ts | 26 ++++++++--- .../src/provider/Layers/CodexProvider.test.ts | 44 ++++++++++++++++++- .../src/provider/Layers/CodexProvider.ts | 32 +++++++++++++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 20 +++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 16 ++++++- .../BranchToolbarBranchSelector.tsx | 21 +++++++-- apps/web/src/modelSelection.ts | 4 ++ apps/web/src/providerModels.ts | 1 + .../src/operations/projects.test.ts | 3 +- packages/contracts/src/model.ts | 14 +++++- packages/contracts/src/server.ts | 1 + packages/contracts/src/settings.test.ts | 8 ++-- packages/contracts/src/settings.ts | 2 +- 17 files changed, 218 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index ee2bde9d971..74fe2f4852a 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -58,6 +58,8 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; type WorkspaceMode = "local" | "worktree"; +const EMPTY_BRANCH_REFS: ReadonlyArray = []; + function pendingTaskDraftKey(messageId: string): string { return `pending-task:${messageId}`; } @@ -348,7 +350,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; - const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false; + // Keep the user's explicit choice separate from the resolved display value: + // only the explicit flag is ever written back to the draft, so the resolved + // value keeps tracking the server setting when the config loads late. + const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; + const startFromOrigin = + draftStartFromOrigin ?? + selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? + true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; @@ -368,6 +377,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedModel = selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? + modelOptions.find((option) => option.isDefault)?.selection ?? modelOptions[0]?.selection ?? null; const selectedModelKey = selectedModel @@ -475,13 +485,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const branchState = useBranches(branchTarget); const branchesLoading = branchState.isPending; + const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS; const availableBranches = useMemo( () => pipe( - branchState.data?.refs ?? [], + allBranchRefs, Arr.filter((branch) => !branch.isRemote), ), - [branchState.data?.refs], + [allBranchRefs], ); const filteredBranches = useMemo(() => { @@ -543,11 +554,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { mode, branch: selectedBranchName, worktreePath: selectedWorktreePath, - startFromOrigin, + ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); }, - [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, startFromOrigin], + [draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], ); const selectBranch = useCallback( @@ -560,11 +571,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { mode: workspaceMode, branch: branch.name, worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), - startFromOrigin, + ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); }, - [selectedProject, selectedProjectDraftKey, startFromOrigin, workspaceMode], + [draftStartFromOrigin, selectedProject, selectedProjectDraftKey, workspaceMode], ); const setStartFromOrigin = useCallback( @@ -597,14 +608,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (workspaceMode !== "worktree" || selectedBranchName !== null) { return; } + // The default may only exist as origin/ (isRemote), which + // availableBranches filters out — search the unfiltered refs for it. const preferredBranch = + allBranchRefs.find((branch) => branch.isDefault) ?? availableBranches.find((branch) => branch.current) ?? - availableBranches.find((branch) => branch.isDefault) ?? null; if (preferredBranch) { selectBranch(preferredBranch); } - }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]); + }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]); const setRuntimeMode = useCallback( (value: RuntimeMode) => { @@ -696,7 +709,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { workspaceMode: mode, branch: workspaceSelection?.branch ?? null, worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), - ...(workspaceSelection?.startFromOrigin ? { startFromOrigin: true } : {}), + // The draft only carries the flag when the user touched it; fall + // back to the resolved default (server settings) so queued tasks + // drain with the same origin mode the composer displayed. + ...((workspaceSelection?.startFromOrigin ?? startFromOrigin) + ? { startFromOrigin: true } + : {}), }, createdAt: metadata.createdAt, }; @@ -707,6 +725,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedProject, selectedProjectDraftKey, + startFromOrigin, ], ); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index e21682414d7..ab859c73b46 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -15,6 +15,7 @@ export type ModelOption = { readonly providerKey: string; readonly providerLabel: string; readonly providerDriver: string; + readonly isDefault: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -78,6 +79,7 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, + isDefault: model.isDefault === true, capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -107,6 +109,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver: fallbackModelSelection.instanceId, + isDefault: false, capabilities: null, selection: fallbackModelSelection, }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 17aeff2d0e3..3e883fa1cf5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3045,7 +3045,7 @@ describe("ClaudeAdapterLive", () => { attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]); + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -3143,10 +3143,11 @@ describe("ClaudeAdapterLive", () => { yield* adapter.sendTurn({ threadId: session.threadId, input: "hello again", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-6", - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "contextWindow", value: "200k" }], + ), attachments: [], }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f6e63eeffad..5ccd011ef09 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -77,6 +77,7 @@ import { isClaudeUltracodeEffort, normalizeClaudeCliEffort, resolveClaudeApiModelId, + resolveClaudeContextWindow, resolveClaudeEffort, } from "./ClaudeProvider.ts"; import { @@ -341,24 +342,18 @@ function selectedClaudeContextWindow( switch (modelSelection?.model) { case "claude-opus-4-8": case "claude-opus-4-7": + // Always 1M at the API; these models expose no contextWindow option. return 1_000_000; } - const optionValue = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); - if (optionValue === "1m") { - return 1_000_000; - } - if (optionValue === "200k") { - return 200_000; - } - const caps = getClaudeModelCapabilities(modelSelection?.model); - const hasContextWindowOption = getProviderOptionDescriptors({ caps }).some( - (descriptor) => descriptor.type === "select" && descriptor.id === "contextWindow", - ); - if (hasContextWindowOption) { - return 200_000; + switch (resolveClaudeContextWindow(modelSelection)) { + case "1m": + return 1_000_000; + case "200k": + return 200_000; + default: + return undefined; } - return undefined; } function finiteNonNegativeInteger(value: unknown): number | undefined { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index ff0d1992454..9df672f54b0 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -76,8 +76,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ id: "contextWindow", label: "Context Window", options: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, ], }), ], @@ -162,8 +162,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ id: "contextWindow", label: "Context Window", options: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, ], }), ], @@ -214,6 +214,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "contextWindow", label: "Context Window", + // Sonnet is 200k-default in Claude Code (1M is opt-in there too). options: [ { value: "200k", label: "200k", isDefault: true }, { value: "1m", label: "1M" }, @@ -243,6 +244,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "contextWindow", label: "Context Window", + // Sonnet is 200k-default in Claude Code (1M is opt-in there too). options: [ { value: "200k", label: "200k", isDefault: true }, { value: "1m", label: "1M" }, @@ -369,8 +371,22 @@ export function isClaudeUltracodeEffort(effort: string | null | undefined): bool return effort === "ultracode"; } +export function resolveClaudeContextWindow( + modelSelection: ModelSelection | undefined, +): string | undefined { + const caps = getClaudeModelCapabilities(modelSelection?.model); + const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}), + }); + const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow"); + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : undefined; +} + export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { - switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { + switch (resolveClaudeContextWindow(modelSelection)) { case "1m": return `${modelSelection.model}[1m]`; default: diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b..2aeebdb2ccd 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,6 @@ import { assert, it } from "@effect/vitest"; -import { mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -102,3 +102,45 @@ it("uses standard routing when the catalog has no default service tier", () => { }, ]); }); + +it("marks the most preferred available model as default", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual( + models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), + [ + { slug: "gpt-5.6-terra", isDefault: true }, + { slug: "gpt-5.4", isDefault: undefined }, + ], + ); +}); + +it("prefers sol over terra when both are available", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); +}); + +it("keeps Codex's own default when no preferred model is available", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); +}); + +it("ignores custom models that shadow a preferred slug", () => { + const models = applyPreferredCodexDefaultModel([ + { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, + ]); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 9306087a0bc..1ed9c750c18 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -22,7 +22,7 @@ import type { ServerProviderModel, ServerProviderSkill, } from "@t3tools/contracts"; -import { ServerSettingsError } from "@t3tools/contracts"; +import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -189,10 +189,36 @@ function parseCodexModelListResponse( slug: model.model, name: toDisplayName(model), isCustom: false, + ...(model.isDefault ? { isDefault: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } +/** + * Prefer our own default-model ranking when one of the preferred slugs is in + * the live catalog; otherwise keep whatever Codex itself flagged as default. + */ +export function applyPreferredCodexDefaultModel( + models: ReadonlyArray, +): ReadonlyArray { + const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.find((slug) => + models.some((model) => model.slug === slug && !model.isCustom), + ); + if (!preferredSlug) { + return models; + } + return models.map((model) => { + if (model.slug === preferredSlug) { + return model.isDefault ? model : { ...model, isDefault: true }; + } + if (!model.isDefault) { + return model; + } + const { isDefault: _isDefault, ...rest } = model; + return rest; + }); +} + function appendCustomCodexModels( models: ReadonlyArray, customModels: ReadonlyArray, @@ -376,7 +402,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun return { account: accountResponse, version, - models: appendCustomCodexModels(models, input.customModels ?? []), + models: applyPreferredCodexDefaultModel( + appendCustomCodexModels(models, input.customModels ?? []), + ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8c627525b4c..9ffd3ed696d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -596,6 +596,26 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("marks the origin default ref as default when no local copy exists", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["remote", "set-head", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/only-local"]); + yield* git(cwd, ["branch", "-D", initialBranch]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const refs = yield* driver.listRefs({ cwd }); + const remoteDefault = refs.refs.find((ref) => ref.name === `origin/${initialBranch}`); + assert.equal(remoteDefault?.isRemote, true); + assert.equal(remoteDefault?.isDefault, true); + }), + ); + it.effect("creates, checks out, renames, and lists refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 6293ff7c29c..471ec10b566 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2217,7 +2217,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* name: refName.name, current: false, isRemote: true, - isDefault: false, + // origin/HEAD's target is the repo default even when no local + // copy of the default branch exists. + isDefault: + defaultBranch !== null && + parsedRemoteRef?.remoteName === "origin" && + parsedRemoteRef.branchName === defaultBranch, worktreePath: null, }; if (parsedRemoteRef) { @@ -2232,9 +2237,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }) : []; - const allBranches = input.includeMatchingRemoteRefs + const combinedBranches = input.includeMatchingRemoteRefs ? [...localBranches, ...remoteBranches] : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]); + // Keep current/default refs on the first page even when the default + // only exists as origin/ (remote refs sort after all locals). + const allBranches = combinedBranches.toSorted((a, b) => { + const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; + const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; + return aPriority - bPriority; + }); const branchesForKind = input.refKind === "local" ? allBranches.filter((ref) => !ref.isRemote) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e2ee24c3608..67ae3a8187d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -422,17 +422,32 @@ export function BranchToolbarBranchSelector({ }); }; + // Default the worktree base to the repo default branch (origin/HEAD), only + // falling back to the checked-out branch when no default is known. + const defaultBranchName = useMemo( + () => refs.find((refName) => refName.isDefault)?.name ?? null, + [refs], + ); + const worktreeBaseBranchCandidate = isInitialBranchesLoadPending + ? null + : (defaultBranchName ?? currentGitBranch); useEffect(() => { if ( effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || - !currentGitBranch + !worktreeBaseBranchCandidate ) { return; } - setThreadBranch(currentGitBranch, null); - }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); + setThreadBranch(worktreeBaseBranchCandidate, null); + }, [ + activeThreadBranch, + activeWorktreePath, + effectiveEnvMode, + setThreadBranch, + worktreeBaseBranchCandidate, + ]); // --------------------------------------------------------------------------- // Combobox / list plumbing diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index f1d527880ed..ec089d766cf 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -75,6 +75,7 @@ export interface AppModelOption { shortName?: string; subProvider?: string; isCustom: boolean; + isDefault?: boolean; } function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { @@ -85,6 +86,7 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti }; if (model.shortName) option.shortName = model.shortName; if (model.subProvider) option.subProvider = model.subProvider; + if (model.isDefault) option.isDefault = true; return option; } @@ -247,7 +249,9 @@ export function resolveAppModelSelectionForInstance( const options = getAppModelOptionsForInstance(settings, entry); return ( resolveSelectableModel(entry.driverKind, selectedModel, options) ?? + options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? + entry.models.find((model) => model.isDefault)?.slug ?? entry.models[0]?.slug ?? null ); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index aa9afcf31e1..9715344cba8 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -92,6 +92,7 @@ export function getDefaultServerModel( ): string { const models = getProviderModels(providers, provider); return ( + models.find((model) => model.isDefault && !model.isCustom)?.slug ?? models.find((model) => !model.isCustom)?.slug ?? models[0]?.slug ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index bf4e2c89392..f3bc72603ac 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + DEFAULT_MODEL, EnvironmentId, ProjectId, CommandId, @@ -139,7 +140,7 @@ describe("add project shared logic", () => { createWorkspaceRootIfMissing: true, defaultModelSelection: { instanceId: "codex", - model: "gpt-5.4", + model: DEFAULT_MODEL, }, }); }); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dddf3f37459..8c74c13b89b 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -133,8 +133,18 @@ const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); -export const DEFAULT_MODEL = "gpt-5.4"; -export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; +export const DEFAULT_MODEL = "gpt-5.6-sol"; + +/** + * Codex default-model preference, most preferred first. The provider snapshot + * marks the first of these present in the live `model/list` response as + * default; when none are available, Codex's own `isDefault` flag wins. + */ +export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ + "gpt-5.6-sol", + "gpt-5.6-terra", +]; +export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 316f09693ec..3d99b8e95a6 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -64,6 +64,7 @@ export const ServerProviderModel = Schema.Struct({ shortName: Schema.optional(TrimmedNonEmptyString), subProvider: Schema.optional(TrimmedNonEmptyString), isCustom: Schema.Boolean, + isDefault: Schema.optional(Schema.Boolean), capabilities: Schema.NullOr(ModelCapabilities), }); export type ServerProviderModel = typeof ServerProviderModel.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f618729c43..a79042a2847 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -88,14 +88,14 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); describe("ServerSettings worktree defaults", () => { - it("defaults start-from-origin off for legacy configs", () => { - expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(false); + it("defaults start-from-origin on for legacy configs", () => { + expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true); }); it("accepts start-from-origin updates", () => { expect( - decodeServerSettingsPatch({ newWorktreesStartFromOrigin: true }).newWorktreesStartFromOrigin, - ).toBe(true); + decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, + ).toBe(false); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fe593f49199..b983aa8a3fa 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -382,7 +382,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), newWorktreesStartFromOrigin: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), + Schema.withDecodingDefault(Effect.succeed(true)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( From 6f34ad3e87eba2ffba66cac5593dae8b680e5b84 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 19:58:39 -0700 Subject: [PATCH 034/110] fix(claude): handle all SDK stream messages; stop spurious work-log warning rows (#4244) Co-authored-by: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 127 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 86 +++++++++++- 2 files changed, 209 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 3e883fa1cf5..2735b69a187 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1711,6 +1711,133 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("consumes undeclared and UX-internal system subtypes without warning rows", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + // Undeclared wire-only roster snapshot + every typed UX-internal + // subtype and top-level type consumed silently: none may surface as + // unknown-subtype warnings. + for (const message of [ + { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "t1", task_type: "local_agent", description: "Say hi" }], + session_id: "session", + uuid: "roster", + }, + { + type: "system", + subtype: "task_updated", + task_id: "t1", + patch: { status: "running" }, + session_id: "session", + uuid: "tu", + }, + { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" }, + { type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" }, + { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" }, + { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, + { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, + { type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" }, + { type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" }, + { + type: "system", + subtype: "notification", + key: "context", + text: "low priority note", + priority: "low", + session_id: "session", + uuid: "notif", + }, + ]) { + harness.query.emit(message as unknown as SDKMessage); + } + // High-priority notifications DO surface as a warning row. + harness.query.emit({ + type: "system", + subtype: "notification", + key: "limit", + text: "context window nearly full", + priority: "high", + session_id: "session", + uuid: "notif-high", + } as unknown as SDKMessage); + // session_state_changed maps to the matching session states. + for (const [state, uuid] of [ + ["running", "ssc-run"], + ["requires_action", "ssc-req"], + ["idle", "ssc-idle"], + ]) { + harness.query.emit({ + type: "system", + subtype: "session_state_changed", + state, + session_id: "session", + uuid, + } as unknown as SDKMessage); + } + // api_retry maps to a session heartbeat, not a warning row. + harness.query.emit({ + type: "system", + subtype: "api_retry", + attempt: 3, + max_retries: 10, + retry_delay_ms: 1000, + error_status: 502, + error: { type: "api_error" }, + session_id: "session", + uuid: "retry", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); + // Exactly one warning: the high-priority notification. Nothing else. + assert.deepEqual( + warnings.map((event) => event.payload.message), + ["context window nearly full"], + ); + const sessionStates = runtimeEvents + .filter((event) => event.type === "session.state.changed") + .map((event) => + event.type === "session.state.changed" + ? `${event.payload.state}:${event.payload.reason ?? ""}` + : "", + ) + .filter( + (entry) => entry.startsWith("running:session_state") || entry.includes("session_state"), + ); + assert.deepEqual(sessionStates, [ + "running:session_state:running", + "waiting:session_state:requires_action", + "ready:session_state:idle", + ]); + const heartbeat = runtimeEvents.find( + (event) => + event.type === "session.state.changed" && + typeof event.payload.reason === "string" && + event.payload.reason.startsWith("api_retry:"), + ); + assert.equal(heartbeat?.type, "session.state.changed"); + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("emits thread token usage updates from Claude task progress", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5ccd011ef09..757d7a00eb2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2585,6 +2585,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }; + // Undeclared-but-real subtypes (absent from the SDK's union, so they can't + // be switch cases): consumed intentionally without emitting, otherwise + // they fall through to the unknown-subtype warning and surface as spurious + // error rows in client work logs. `background_tasks_changed` is a roster + // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the + // authoritative per-agent data and the typed background_tasks control + // request is the reconciliation source. + if ((message.subtype as string) === "background_tasks_changed") { + return; + } + switch (message.subtype) { case "init": yield* offerRuntimeEvent({ @@ -2697,6 +2708,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; + // Task state patch (status/backgrounded/end_time). No runtime mapping + // yet — the terminal task_notification reports the outcome — but it + // must not surface as an unknown-subtype warning row. + case "task_updated": + return; case "task_notification": yield* emitThreadTokenUsage( context, @@ -2741,6 +2757,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; case "thinking_tokens": return; + case "api_retry": + // Transport-level retry heartbeat. Surfacing each attempt as a + // warning row spammed the work log (10 rows during a 502 storm); + // the terminal result/error path reports the actual failure. Keep + // the session visibly alive instead. + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: "running", + reason: `api_retry:${message.attempt}/${message.max_retries}`, + }, + }); + return; + case "session_state_changed": + // Authoritative turn-over signal from the CLI. + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: + message.state === "running" + ? "running" + : message.state === "requires_action" + ? "waiting" + : "ready", + reason: `session_state:${message.state}`, + }, + }); + return; + case "notification": + // User-facing CLI notification (e.g. context-limit warnings). Only + // high-priority ones warrant a work-log row. + if (message.priority === "high" || message.priority === "immediate") { + yield* emitRuntimeWarning(context, message.text, message); + } + return; + // Inner protocol/UX details with no T3 surface today — consumed + // deliberately so they don't masquerade as unknown-subtype warnings. + case "model_refusal_fallback": + case "local_command_output": + case "plugin_install": + case "commands_changed": + case "memory_recall": + case "elicitation_complete": + return; case "permission_denied": yield* offerRuntimeEvent({ ...base, @@ -2760,13 +2822,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( message, ); return; - default: + default: { + // Exhaustiveness guard: every subtype in the SDK's typed union is + // handled above, so `message` narrows to never here — a new SDK + // release adding a subtype fails this typecheck instead of silently + // warning at runtime. The runtime fallback still catches undeclared + // wire-only subtypes (like background_tasks_changed used to be). + message satisfies never; + const unknownMessage = message as never as { subtype: string }; yield* emitRuntimeWarning( context, - describeUnknownSdkMessage(`Claude system message '${message.subtype}'`, message), + describeUnknownSdkMessage(`Claude system message '${unknownMessage.subtype}'`, message), message, ); return; + } } }); @@ -2874,13 +2944,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( case "rate_limit_event": yield* handleSdkTelemetryMessage(context, message); return; - default: + // Composer prompt suggestions have no T3 surface; consumed deliberately. + case "prompt_suggestion": + return; + default: { + // Exhaustiveness guard (see handleSystemMessage): new SDK top-level + // message types fail typecheck here instead of warning at runtime. + message satisfies never; + const unknownMessage = message as never as { type: string }; yield* emitRuntimeWarning( context, - describeUnknownSdkMessage(`Claude SDK message '${message.type}'`, message), + describeUnknownSdkMessage(`Claude SDK message '${unknownMessage.type}'`, message), message, ); return; + } } }); From 32c6012dabdbd0eb178b25ea4225d889ec8f6475 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 22 Jul 2026 03:36:34 -0700 Subject: [PATCH 035/110] Sidebar v2 beta: flat thread list with a server-backed settled lifecycle (#4026) Co-authored-by: Claude Fable 5 Co-authored-by: maria-rcks --- .../settings/DesktopClientSettings.test.ts | 2 + .../archive/archivedThreadList.test.ts | 2 + .../src/features/home/HomeRouteScreen.tsx | 7 +- apps/mobile/src/features/home/HomeScreen.tsx | 386 +++- .../src/features/home/homeListItems.test.ts | 2 + .../src/features/home/homeThreadList.test.ts | 2 + .../features/home/thread-swipe-actions.tsx | 20 +- .../src/features/home/useThreadListActions.ts | 128 +- .../features/settings/SettingsRouteScreen.tsx | 33 + .../features/threads/thread-list-v2-items.tsx | 329 ++++ .../src/features/threads/threadListV2.test.ts | 220 +++ .../src/features/threads/threadListV2.ts | 167 ++ apps/mobile/src/lib/repositoryGroups.test.ts | 2 + apps/mobile/src/lib/threadActivity.test.ts | 2 + .../src/persistence/mobile-preferences.ts | 10 + apps/mobile/src/state/use-thread-selection.ts | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/OrchestrationEngine.test.ts | 2 + .../Layers/ProjectionPipeline.test.ts | 64 + .../Layers/ProjectionPipeline.ts | 34 + .../Layers/ProjectionSnapshotQuery.test.ts | 113 ++ .../Layers/ProjectionSnapshotQuery.ts | 20 + apps/server/src/orchestration/Schemas.ts | 4 + .../orchestration/commandInvariants.test.ts | 4 + .../src/orchestration/decider.settled.test.ts | 521 +++++ apps/server/src/orchestration/decider.ts | 259 ++- .../orchestration/projector.settled.test.ts | 88 + .../src/orchestration/projector.test.ts | 2 + apps/server/src/orchestration/projector.ts | 28 + .../Layers/ProjectionRepositories.test.ts | 56 + .../persistence/Layers/ProjectionThreads.ts | 10 + apps/server/src/persistence/Migrations.ts | 2 + .../033_ProjectionThreadsSettled.ts | 23 + .../persistence/Services/ProjectionThreads.ts | 2 + .../Layers/ProviderSessionReaper.test.ts | 2 + .../src/relay/AgentAwarenessRelay.test.ts | 6 + apps/server/src/server.test.ts | 6 + apps/web/src/components/AppSidebarLayout.tsx | 14 +- .../web/src/components/ChatView.logic.test.ts | 2 + apps/web/src/components/ChatView.logic.ts | 2 + apps/web/src/components/ChatView.tsx | 1 + .../components/CommandPalette.logic.test.ts | 2 + apps/web/src/components/Sidebar.logic.test.ts | 98 + apps/web/src/components/Sidebar.logic.ts | 65 + apps/web/src/components/Sidebar.tsx | 122 +- apps/web/src/components/SidebarV2.tsx | 1707 +++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 6 +- apps/web/src/components/chat/ChatHeader.tsx | 23 + .../components/settings/BetaSettingsPanel.tsx | 108 ++ .../settings/SettingsSidebarNav.tsx | 3 + .../src/components/sidebar/SidebarChrome.tsx | 127 ++ apps/web/src/hooks/useThreadActions.ts | 109 +- apps/web/src/index.css | 73 +- apps/web/src/lib/chatThreadActions.test.ts | 21 + apps/web/src/lib/chatThreadActions.ts | 8 + apps/web/src/lib/threadSort.test.ts | 2 + apps/web/src/newThreadPickerBus.ts | 14 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.tsx | 14 + apps/web/src/routes/settings.beta.tsx | 11 + apps/web/src/state/entities.ts | 10 + apps/web/src/worktreeCleanup.test.ts | 2 + packages/client-runtime/package.json | 4 + .../src/operations/commands.test.ts | 39 +- .../client-runtime/src/operations/commands.ts | 22 + .../client-runtime/src/state/entities.test.ts | 2 + .../src/state/shellReducer.test.ts | 2 + .../src/state/threadCommands.ts | 18 + .../client-runtime/src/state/threadDetail.ts | 2 + .../src/state/threadReducer.test.ts | 58 + .../client-runtime/src/state/threadReducer.ts | 24 + .../src/state/threadSettled.test.ts | 345 ++++ .../client-runtime/src/state/threadSettled.ts | 147 ++ .../src/state/threads-sync.test.ts | 2 + packages/contracts/src/environment.ts | 4 + packages/contracts/src/orchestration.test.ts | 115 ++ packages/contracts/src/orchestration.ts | 52 + packages/contracts/src/settings.test.ts | 21 + packages/contracts/src/settings.ts | 16 + 79 files changed, 5834 insertions(+), 165 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-list-v2-items.tsx create mode 100644 apps/mobile/src/features/threads/threadListV2.test.ts create mode 100644 apps/mobile/src/features/threads/threadListV2.ts create mode 100644 apps/server/src/orchestration/decider.settled.test.ts create mode 100644 apps/server/src/orchestration/projector.settled.test.ts create mode 100644 apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts create mode 100644 apps/web/src/components/SidebarV2.tsx create mode 100644 apps/web/src/components/settings/BetaSettingsPanel.tsx create mode 100644 apps/web/src/components/sidebar/SidebarChrome.tsx create mode 100644 apps/web/src/newThreadPickerBus.ts create mode 100644 apps/web/src/routes/settings.beta.tsx create mode 100644 packages/client-runtime/src/state/threadSettled.test.ts create mode 100644 packages/client-runtime/src/state/threadSettled.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..47800f3192d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, favorites: [], providerModelPreferences: {}, + sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", @@ -27,6 +28,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + sidebarV2Enabled: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 6cd530ab37d..697d13e7c47 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 49cf06d85ec..502d2bab1b5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -28,7 +28,8 @@ export function HomeRouteScreen() { const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); - const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = + useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -111,6 +112,8 @@ export function HomeRouteScreen() { } onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onSettleThread={settleThread} + onUnsettleThread={unsettleThread} onEnvironmentChange={setSelectedEnvironmentId} onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) @@ -120,6 +123,8 @@ export function HomeRouteScreen() { onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onSelectThread={(thread) => { + // Settled threads are live shells: opening one is plain + // navigation, and sending a message un-settles server-side. navigation.navigate("Thread", { environmentId: thread.environmentId, threadId: thread.id, diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index dcc56fd77c5..d1339a9bb91 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -14,18 +14,22 @@ import type { } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Platform, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, FlatList, Platform, Pressable, ScrollView, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; +import { cn } from "../../lib/cn"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { PendingTaskListRow, @@ -33,6 +37,8 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; +import { ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, @@ -74,6 +80,9 @@ interface HomeScreenProps { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + /** Resolves true iff the settle was dispatched and succeeded. */ + readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -82,6 +91,10 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; +// v2 settled-tail paging: recent history is the common lookup; the deep +// tail stays behind an explicit Show more. +const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; +const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and @@ -156,6 +169,80 @@ function HomeTopContentSpacer() { return ; } +function ThreadListV2ProjectScope(props: { + readonly projects: ReadonlyArray; + readonly selectedKey: string | null; + readonly onChange: (key: string | null) => void; +}) { + if (props.projects.length === 0) return null; + + return ( + + {props.projects.length > 1 ? ( + props.onChange(null)} + className={cn( + "min-h-8 items-center justify-center rounded-lg border px-3", + props.selectedKey === null + ? "border-border bg-subtle-strong" + : "border-black/15 dark:border-white/15", + )} + > + All + + ) : null} + {props.projects.map((project) => { + const key = scopedProjectKey(project.environmentId, project.id); + const selected = props.selectedKey === key; + return ( + props.onChange(selected ? null : key)} + className={cn( + "min-h-8 flex-row items-center gap-1.5 rounded-lg border py-1 pl-2 pr-3", + selected ? "border-border bg-subtle-strong" : "border-black/15 dark:border-white/15", + )} + > + + + {project.title} + + + ); + })} + + ); +} + /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { @@ -163,6 +250,9 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); + const threadListV2Enabled = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -267,6 +357,193 @@ export function HomeScreen(props: HomeScreenProps) { return map; }, [props.projects]); + const projectByKey = useMemo(() => { + const map = new Map(); + for (const project of props.projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project); + } + return map; + }, [props.projects]); + + const [v2ProjectScopeKey, setV2ProjectScopeKey] = useState(null); + const v2ScopeProjects = useMemo( + () => + props.selectedEnvironmentId === null + ? props.projects + : props.projects.filter((project) => project.environmentId === props.selectedEnvironmentId), + [props.projects, props.selectedEnvironmentId], + ); + const v2ScopedProject = useMemo( + () => + v2ProjectScopeKey === null + ? null + : (v2ScopeProjects.find( + (project) => scopedProjectKey(project.environmentId, project.id) === v2ProjectScopeKey, + ) ?? null), + [v2ProjectScopeKey, v2ScopeProjects], + ); + useEffect(() => { + if (v2ProjectScopeKey !== null && v2ScopedProject === null) { + setV2ProjectScopeKey(null); + } + }, [v2ProjectScopeKey, v2ScopedProject]); + + // Thread List v2 (beta): one flat list in creation order, no grouping. + // Settled threads collapse into a recency tail below the card block. + // Settled threads stay in the live shell stream (settled ≠ archived), so + // the partition works directly off live shells — no snapshot merging or + // optimistic holds. + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition (mirrors web). + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + const handleSettleThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onSettleThread(thread); + }, + [props.onSettleThread], + ); + const handleDeleteThread = props.onDeleteThread; + const handleUnsettleThread = props.onUnsettleThread; + // The settled tail renders in pages; expansion resets when the filter + // context changes so environment/search flips never inherit a deep page. + const [settledVisibleCount, setSettledVisibleCount] = useState( + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + ); + const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); + } + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), + [], + ); + // now is quantized to the minute and ticks so the inactivity auto-settle + // boundary is actually crossed while the app stays open (mirrors web); + // without a clock dependency the partition memoizes a frozen "now". + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + if (!threadListV2Enabled) return; + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, [threadListV2Enabled]); + // Threads on servers without the settlement capability never classify as + // settled (the user could neither un-settle nor pin them). + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const threadListV2Layout = useMemo(() => { + if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + // Settled threads are live shells; archived threads keep their original + // "hidden from lists" meaning. + return buildThreadListV2Items({ + threads: props.threads.filter((thread) => thread.archivedAt === null), + environmentId: props.selectedEnvironmentId, + projectRef: + v2ScopedProject === null + ? null + : { + environmentId: v2ScopedProject.environmentId, + projectId: v2ScopedProject.id, + }, + searchQuery: props.searchQuery, + changeRequestStateByKey, + settlementEnvironmentIds, + settledLimit: settledVisibleCount, + now: `${nowMinute}:00.000Z`, + }); + }, [ + changeRequestStateByKey, + nowMinute, + settledVisibleCount, + settlementEnvironmentIds, + props.searchQuery, + props.selectedEnvironmentId, + props.threads, + threadListV2Enabled, + v2ScopedProject, + ]); + const threadListV2Items = threadListV2Layout.items; + + const renderV2Item = useCallback( + ({ item }: { readonly item: ThreadListV2Item }) => ( + + provider.instanceId === + (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), + )?.driver ?? null + } + onSelectThread={props.onSelectThread} + onDeleteThread={handleDeleteThread} + onArchiveThread={props.onArchiveThread} + settlementSupported={settlementEnvironmentIds.has(item.thread.environmentId)} + onSettleThread={handleSettleThread} + onUnsettleThread={handleUnsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={ + projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? + null + } + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + /> + ), + [ + handleChangeRequestState, + handleDeleteThread, + handleSettleThread, + handleSwipeableClose, + handleSwipeableWillOpen, + handleUnsettleThread, + projectByKey, + projectCwdByKey, + props.onArchiveThread, + props.onSelectThread, + serverConfigs, + settlementEnvironmentIds, + ], + ); + const v2KeyExtractor = useCallback( + (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`, + [], + ); + const extraData = useMemo( () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), [props.savedConnectionsById, projectCwdByKey], @@ -360,6 +637,10 @@ export function HomeScreen(props: HomeScreenProps) { const keyExtractor = useCallback((item: HomeListItem) => item.key, []); /* Empty states */ + // The signal must ignore the search/environment filters: an active query + // that matches nothing needs the in-list "No results" state, not the + // full-page "No threads yet". Settled threads are unarchived live shells, + // so the v1 check already covers v2. const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; const hasResults = projectGroups.length > 0; @@ -436,6 +717,44 @@ export function HomeScreen(props: HomeScreenProps) { ); + // v2 renders queued offline tasks above the thread cards — they are not + // thread shells, so the v2 item builder never sees them, but they must + // stay visible and deletable while their environment is offline. They + // respect the same environment scope and search filter as the list. + const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2PendingTasks = props.pendingTasks.filter( + (pendingTask) => + (props.selectedEnvironmentId === null || + pendingTask.message.environmentId === props.selectedEnvironmentId) && + (v2ScopedProject === null || + (pendingTask.message.environmentId === v2ScopedProject.environmentId && + pendingTask.creation.projectId === v2ScopedProject.id)) && + (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ); + const v2ListHeader = ( + <> + {listHeader} + + {v2PendingTasks.map((pendingTask, index) => ( + + ))} + + ); + const listEmpty = !hasResults ? ( hasSearchQuery ? ( @@ -448,6 +767,69 @@ export function HomeScreen(props: HomeScreenProps) { ) ) : null; + // Self-contained: v1's listEmpty keys off projectGroups, which ignores the + // v2 project scope, so it can be null (results elsewhere) while this list + // is empty. Search outranks the scope — "No results" names the actionable + // fact when a query is active. Pending tasks render in the header, so the + // list showing them isn't empty in the user's eyes. + const v2ListEmpty = + v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( + + ) : v2ScopedProject !== null ? ( + + ) : ( + listEmpty + ); + + if (threadListV2Enabled) { + return ( + + + 0 ? ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) + + + ) : null + } + ListEmptyComponent={v2ListEmpty} + style={{ flex: 1 }} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + showsVerticalScrollIndicator={false} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + scrollEventThrottle={16} + contentContainerStyle={{ + paddingBottom: + Platform.OS === "ios" + ? Math.max(insets.bottom, 24) + 96 + : Math.max(insets.bottom, 16) + 88, + }} + /> + + {connectionStatus} + + ); + } return ( diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 36e98ef129f..c5a9f2c6bbc 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -47,6 +47,8 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 8f13000b9e2..46d1173b0dd 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 84a9f32ee5e..666169f3039 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -167,6 +167,13 @@ export function ThreadSwipeable(props: { /** Disables NEW swipe activations (e.g. while the list scrolls). */ readonly enabled?: boolean; readonly enableTrackpadSwipe?: boolean; + /** + * What a full swipe commits: "delete" (default, v1 behavior — the Delete + * button stretches) or "primary" — the advertised primary action fires and + * its button stretches instead. A full swipe must always match the action + * the stretching button advertises. + */ + readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeWidth: number; readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; @@ -239,7 +246,11 @@ export function ThreadSwipeable(props: { if (fullSwipeArmedRef.current) { fullSwipeArmedRef.current = false; methods.close(); - props.onDelete(); + if (props.fullSwipeAction === "primary") { + props.primaryAction.onPress(); + } else { + props.onDelete(); + } } }} overshootFriction={1} @@ -247,6 +258,7 @@ export function ThreadSwipeable(props: { renderRightActions={(_progress, translation, methods) => ( void; readonly onFullSwipeArmedChange: (armed: boolean) => void; @@ -430,6 +443,7 @@ export function ThreadSwipeActions(props: { readonly threadTitle: string; readonly translation: SharedValue; }) { + const fullSwipeIsPrimary = props.fullSwipeAction === "primary"; useAnimatedReaction( () => -props.translation.value >= props.fullSwipeThreshold, (armed, previous) => { @@ -457,7 +471,7 @@ export function ThreadSwipeActions(props: { icon={props.primaryAction.icon} label={props.primaryAction.label} onPress={props.primaryAction.onPress} - stretchesOnFullSwipe={false} + stretchesOnFullSwipe={fullSwipeIsPrimary} translation={props.translation} /> diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8effdc942d9..e200eb7acde 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,4 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -6,19 +7,37 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; -type ThreadListAction = "archive" | "unarchive" | "delete"; +/** Version skew: never send settle/unsettle to a server that predates them + (capability defaults false on decode for older servers). */ +function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSettlement === true + ); +} + +type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; + +const ACTION_VERBS: Record = { + archive: "archived", + unarchive: "unarchived", + delete: "deleted", + settle: "settled", + unsettle: "un-settled", +}; function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause): string { const error = Cause.squash(cause); if (error instanceof Error && error.message.trim().length > 0) { return error.message; } - const verb = - action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted"; - return `The thread could not be ${verb}.`; + return `The thread could not be ${ACTION_VERBS[action]}.`; } function selectionHaptic(): void { @@ -28,54 +47,115 @@ function selectionHaptic(): void { function actionFailureTitle(action: ThreadListAction): string { if (action === "archive") return "Could not archive thread"; if (action === "unarchive") return "Could not unarchive thread"; + if (action === "settle") return "Could not settle thread"; + if (action === "unsettle") return "Could not un-settle thread"; return "Could not delete thread"; } +/** Resolves to true iff the action was dispatched and succeeded. */ function useThreadActionExecutor( onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, ) { const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false }); + const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const inFlightThreadKeys = useRef(new Set()); const executeAction = useCallback( async (action: ThreadListAction, thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); if (inFlightThreadKeys.current.has(key)) { - return; + return false; } inFlightThreadKeys.current.add(key); selectionHaptic(); try { - const mutation = - action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation; - const result = await mutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + if ( + (action === "settle" || action === "unsettle") && + !environmentSupportsSettlement(thread.environmentId) + ) { + Alert.alert( + actionFailureTitle(action), + "This environment's server does not support settling yet. Update the server to use Settle.", + ); + return false; + } + // Settle may only target what effectiveSettled could classify as + // settled: not starting/running sessions, not threads waiting on + // approvals or user input. Anything else would hide live work. + if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { + Alert.alert( + actionFailureTitle(action), + "This thread still needs attention. Resolve or interrupt it first, then try again.", + ); + return false; + } + // Archive keeps its original, narrower guard: never interrupt a + // thread mid-turn. + if ( + action === "archive" && + thread.session?.status === "running" && + thread.session.activeTurnId != null + ) { + Alert.alert( + actionFailureTitle(action), + "This thread is working. Interrupt it first, then try again.", + ); + return false; + } + const result = + action === "unsettle" + ? // reason "user" pins the thread active: auto-settle stays + // suppressed until real activity clears the pin server-side. + await unsettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + : await ( + action === "settle" + ? settleMutation + : action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation + )({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); - return; + return false; + } + // Settled threads stay in the live shell stream; only the archive + // lifecycle still feeds the archived-snapshot surface. + if (action === "archive" || action === "unarchive" || action === "delete") { + refreshArchivedThreadsForEnvironment(thread.environmentId); } onCompleted?.(action, thread); + return true; } finally { inFlightThreadKeys.current.delete(key); } }, - [archiveMutation, deleteMutation, onCompleted, unarchiveMutation], + [ + archiveMutation, + deleteMutation, + onCompleted, + settleMutation, + unarchiveMutation, + unsettleMutation, + ], ); return executeAction; } function useConfirmDeleteThread( - executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, + executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, ) { return useCallback( (thread: EnvironmentThreadShell) => { @@ -111,6 +191,8 @@ function useConfirmDeleteThread( export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly settleThread: (thread: EnvironmentThreadShell) => Promise; + readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); @@ -120,10 +202,18 @@ export function useThreadListActions(): { }, [executeAction], ); + const settleThread = useCallback( + async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, + [executeAction], + ); + const unsettleThread = useCallback( + async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, + [executeAction], + ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); - return { archiveThread, confirmDeleteThread }; + return { archiveThread, confirmDeleteThread, settleThread, unsettleThread }; } export function useArchivedThreadListActions( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 6c67a4d89e8..9aea392555a 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -123,6 +123,8 @@ function LocalSettingsRouteScreen() { + + @@ -506,6 +508,8 @@ function ConfiguredSettingsRouteScreen() { + + @@ -514,6 +518,35 @@ function ConfiguredSettingsRouteScreen() { ); } +/** + * Device-local beta toggles. Mobile has no client-settings sync, so this is + * the counterpart of web's Settings → Beta backed by mobile preferences. + */ +function BetaSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.threadListV2Enabled === true + : false; + + return ( + + + savePreferences({ threadListV2Enabled: value })} + /> + + + One flat thread list in creation order. Active work renders as cards; settled threads + collapse to compact rows. Switch back any time. + + + ); +} + function AppSettingsSection() { const icon = useThemeColor("--color-icon"); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx new file mode 100644 index 00000000000..9dd41f3cd60 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -0,0 +1,329 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { MenuAction } from "@react-native-menu/menu"; +import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; +import { Platform, Pressable, useWindowDimensions, View } from "react-native"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { relativeTime } from "../../lib/time"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useThreadPr } from "../../state/use-thread-pr"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; + +/** + * Thread List v2 rows mirror the web sidebar's compact tonal cards and + * receded settled tail while retaining native swipe and long-press actions. + */ + +const MONO_FONT = Platform.select({ + ios: "Menlo", + android: "monospace", + default: "monospace", +}); + +const STATUS_LABEL_BY_STATUS: Partial< + Record +> = { + approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, + input: { label: "Input", className: "text-amber-700 dark:text-amber-300" }, + working: { label: "Working", className: "text-blue-600 dark:text-blue-400" }, + failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, +}; + +function threadTimeLabel(thread: EnvironmentThreadShell): string { + return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); +} + +// Menus stay lifecycle-focused: settle/un-settle plus delete. Archive keeps +// its own surface (thread screen / settings) rather than crowding the row. +const CARD_MENU_ACTIONS: MenuAction[] = [ + { id: "settle", title: "Settle", image: "checkmark" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +const SLIM_MENU_ACTIONS: MenuAction[] = [ + { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +// Pre-settlement servers: no lifecycle items, archive fills the gap. +const LEGACY_MENU_ACTIONS: MenuAction[] = [ + { id: "archive", title: "Archive", image: "archivebox" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() { + const borderColor = useThemeColor("--color-border"); + return ( + + Settled + + + ); +}); + +export const ThreadListV2Row = memo(function ThreadListV2Row(props: { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + readonly showSettledDivider: boolean; + readonly project: EnvironmentProject | null; + readonly providerDriver: string | null; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSettleThread: (thread: EnvironmentThreadShell) => void; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + /** False on environments whose server predates thread.settle/unsettle: + swipe + menu fall back to Archive instead of failing on use. */ + readonly settlementSupported: boolean; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + /** Reports this row's live PR state up so the partition can auto-settle + merged/closed work (mirrors web's onChangeRequestState). */ + readonly onChangeRequestState?: ( + threadKey: string, + state: "open" | "closed" | "merged" | null, + ) => void; + readonly projectCwd?: string | null; + readonly simultaneousSwipeGesture?: ComponentProps< + typeof ThreadSwipeable + >["simultaneousWithExternalGesture"]; +}) { + const { width: windowWidth } = useWindowDimensions(); + const { + thread, + variant, + onSelectThread, + onDeleteThread, + onSettleThread, + onUnsettleThread, + onArchiveThread, + onChangeRequestState, + } = props; + + const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); + const prState = pr?.state ?? null; + const threadKey = `${thread.environmentId}:${thread.id}`; + useEffect(() => { + onChangeRequestState?.(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + const screenColor = useThemeColor("--color-screen"); + + const status = resolveThreadListV2Status(thread); + const statusLabel = STATUS_LABEL_BY_STATUS[status]; + const timeLabel = threadTimeLabel(thread); + + const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); + const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); + const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); + const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "settle") handleSettle(); + if (nativeEvent.event === "unsettle") handleUnsettle(); + if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "delete") handleDelete(); + }, + [handleArchive, handleDelete, handleSettle, handleUnsettle], + ); + + // Swipe: the v2 primary action is the lifecycle transition. Every settled + // row can un-settle — explicit settles clear the override, auto-settled + // rows get pinned active until real activity clears the pin. + const canUnsettle = variant === "slim"; + const primaryAction = useMemo(() => { + // Pre-settlement server: archive is the swipe action, as in v1. (Slim + // rows cannot occur here — unsupported environments never classify as + // settled.) + if (!props.settlementSupported) { + return { + accessibilityLabel: `Archive ${thread.title}`, + icon: "archivebox" as const, + label: "Archive", + onPress: handleArchive, + }; + } + return canUnsettle + ? { + accessibilityLabel: `Un-settle ${thread.title}`, + icon: "arrow.uturn.backward" as const, + label: "Un-settle", + onPress: handleUnsettle, + } + : { + accessibilityLabel: `Settle ${thread.title}`, + icon: "checkmark" as const, + label: "Settle", + onPress: handleSettle, + }; + }, [ + canUnsettle, + handleArchive, + handleSettle, + handleUnsettle, + props.settlementSupported, + thread.title, + ]); + + const rowContent = (close: () => void) => + variant === "card" ? ( + { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + {props.project ? ( + + ) : null} + + {props.project?.title ?? ""} + + + {statusLabel?.label ?? timeLabel} + + + + {thread.title} + + + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : thread.branch ? ( + + {thread.branch} + + ) : ( + + )} + {props.providerDriver ? ( + + + + ) : null} + + + + + + ) : ( + { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + {/* Settled history recedes: dimmed favicon + muted title. */} + + {props.project ? ( + + + + ) : null} + + {thread.title} + + + {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} + + + + ); + + return ( + <> + {props.showSettledDivider ? : null} + + {(close) => ( + + {rowContent(close)} + + )} + + + ); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts new file mode 100644 index 00000000000..80edc86124b --- /dev/null +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -0,0 +1,220 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildThreadListV2Items, + resolveThreadListV2Status, + sortThreadsForListV2, +} from "./threadListV2"; + +const environmentId = EnvironmentId.make("environment-1"); + +function makeThread( + input: Partial & Pick, +): EnvironmentThreadShell { + return { + environmentId, + projectId: ProjectId.make("project-1"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +const NOW = "2026-06-02T00:00:00.000Z"; + +describe("resolveThreadListV2Status", () => { + it("prioritizes approval over a running session", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + hasPendingApprovals: true, + session: { + threadId: ThreadId.make("t"), + status: "running", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }); + expect(resolveThreadListV2Status(thread)).toBe("approval"); + }); + + it("resolves ready for quiescent threads", () => { + expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( + "ready", + ); + }); +}); + +describe("sortThreadsForListV2", () => { + it("orders by creation time, newest first, ignoring activity", () => { + const sorted = sortThreadsForListV2([ + { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, + { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, + { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); + }); +}); + +describe("buildThreadListV2Items", () => { + it("partitions settled threads into a slim tail with one divider", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + settledAt: NOW, + }), + makeThread({ + id: ThreadId.make("settled-2"), + title: "Settled 2", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["active", "card"], + ["settled", "slim"], + ["settled-2", "slim"], + ]); + expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]); + expect(items.map((item) => item.isLast)).toEqual([false, false, true]); + }); + + it("keeps cards in creation order while settled sorts by recency", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("older-created"), + title: "Older", + createdAt: "2026-06-01T08:00:00.000Z", + updatedAt: NOW, // recent activity must NOT promote it + }), + makeThread({ + id: ThreadId.make("newer-created"), + title: "Newer", + createdAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); + }); + + it("keeps settled threads in the tail and filters by search query", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }), + makeThread({ id: ThreadId.make("miss"), title: "Greeting" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Fix login again", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "login", + now: NOW, + }); + + expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["match", "card"], + ["settled", "slim"], + ]); + }); + + it("scopes the flat list to one project", () => { + const otherProjectId = ProjectId.make("project-2"); + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("included"), title: "Included" }), + makeThread({ + id: ThreadId.make("excluded"), + projectId: otherProjectId, + title: "Excluded", + }), + ], + environmentId: null, + projectRef: { environmentId, projectId: ProjectId.make("project-1") }, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["included"]); + }); +}); + +describe("buildThreadListV2Items settled paging", () => { + it("caps the settled tail at settledLimit and reports the hidden count", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + ...Array.from({ length: 4 }, (_, index) => + makeThread({ + id: ThreadId.make(`settled-${index}`), + title: `Settled ${index}`, + settledOverride: "settled", + settledAt: NOW, + latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, + // A turn adopted the message (same requestedAt): without it the + // thread reads as a queued turn start, which never settles. + latestTurn: { + turnId: TurnId.make(`turn-${index}`), + state: "completed", + requestedAt: `2026-06-01T0${index}:00:00.000Z`, + startedAt: `2026-06-01T0${index}:00:00.000Z`, + completedAt: `2026-06-01T0${index}:10:00.000Z`, + assistantMessageId: null, + }, + }), + ), + ]; + + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + settledLimit: 2, + now: NOW, + }); + + expect(layout.hiddenSettledCount).toBe(2); + expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2); + // Most recent settled first — the hidden ones are the oldest. + expect(layout.items.map((item) => item.thread.id)).toEqual([ + "active", + "settled-3", + "settled-2", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts new file mode 100644 index 00000000000..2a1df18309c --- /dev/null +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -0,0 +1,167 @@ +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +/** + * Thread List v2 model, ported from the web sidebar v2 + * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). + * + * Four visual states, three colors: color is reserved for "act now" + * (approval), "in motion" (working), and "broken" (failed). Ready is the + * unlabeled resting state. + */ +export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; + +export function resolveThreadListV2Status( + thread: Pick, +): ThreadListV2Status { + if (thread.hasPendingApprovals) { + return "approval"; + } + if (thread.hasPendingUserInput) { + return "input"; + } + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + if (thread.session?.status === "error") { + return "failed"; + } + return "ready"; +} + +/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not + poison the whole ordering, so it sinks to the epoch instead. */ +function parseTimestampMs(isoDate: string): number { + const parsed = Date.parse(isoDate); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** First VALID timestamp wins: a present-yet-malformed string falls through + to the next candidate rather than sinking the row to the epoch. */ +function firstValidTimestampMs(...candidates: ReadonlyArray): number { + for (const candidate of candidates) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +/** + * v2 sort: static creation order, newest thread on top. Activity NEVER + * reorders the list — a row holds its position from open until settled, so + * the screen only moves at lifecycle transitions. Mirrors web's + * sortThreadsForSidebarV2. + */ +export function sortThreadsForListV2( + threads: readonly T[], +): T[] { + // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 + // change-by-copy array methods. + return [...threads].sort( + (left, right) => + parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + left.id.localeCompare(right.id), + ); +} + +export interface ThreadListV2Item { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + /** First settled row after the card block draws the SETTLED divider. */ + readonly showSettledDivider: boolean; + readonly isLast: boolean; +} + +export interface ThreadListV2Layout { + readonly items: ThreadListV2Item[]; + /** Settled threads beyond the render limit (behind "Show more"). */ + readonly hiddenSettledCount: number; +} + +/** + * Partitions visible threads into the active card block (creation order) and + * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` + * mirrors the web default of 3 — mobile has no client-settings sync yet, so + * the default is fixed here rather than user-configurable. + */ +export function buildThreadListV2Items(input: { + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly projectRef?: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + } | null; + readonly searchQuery: string; + /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ + readonly changeRequestStateByKey?: ReadonlyMap; + /** Environments whose server supports thread.settle/unsettle. Threads on + other environments never classify as settled — the user could neither + un-settle nor pin them. Absent = no gating (tests). */ + readonly settlementEnvironmentIds?: ReadonlySet; + readonly autoSettleAfterDays?: number; + /** Max settled rows to render; the rest are counted, not built. */ + readonly settledLimit?: number; + /** Injectable for tests; defaults to now. */ + readonly now?: string; +}): ThreadListV2Layout { + const now = input.now ?? new Date().toISOString(); + const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; + const query = input.searchQuery.trim().toLocaleLowerCase(); + + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of input.threads) { + // Callers pass live (unarchived) shells; settled threads are among them + // and partition into the tail via effectiveSettled. + if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; + if ( + input.projectRef != null && + (thread.environmentId !== input.projectRef.environmentId || + thread.projectId !== input.projectRef.projectId) + ) { + continue; + } + if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; + const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; + const changeRequestState = + input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; + if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } + } + + const orderedActive = sortThreadsForListV2(active); + const orderedSettled = [...settled].sort( + (left, right) => + firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - + firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + ); + const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; + const visibleSettled = + orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; + + const items: ThreadListV2Item[] = []; + for (const thread of orderedActive) { + items.push({ thread, variant: "card", showSettledDivider: false, isLast: false }); + } + for (const [index, thread] of visibleSettled.entries()) { + items.push({ + thread, + variant: "slim", + showSettledDivider: index === 0, + isLast: false, + }); + } + const last = items.at(-1); + if (last) { + items[items.length - 1] = { ...last, isLast: true }; + } + return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length }; +} diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 8cea5df2307..ab4311524ce 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -38,6 +38,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 23b47fc625f..5f9f099a46c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -50,6 +50,8 @@ function makeThread( checkpoints: [], session: null, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 6971570b0e6..1138ad2b655 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -22,6 +22,12 @@ export interface Preferences { readonly codeWordBreak?: boolean; readonly connectOnboardingOptOutAccounts?: ReadonlyArray; readonly collapsedProjectGroups?: readonly string[]; + /** + * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no + * client-settings sync, so the flat v2 thread list is opted into per + * device. + */ + readonly threadListV2Enabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -71,6 +77,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { codeWordBreak?: boolean; connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; + threadListV2Enabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -97,6 +104,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { (key): key is string => typeof key === "string", ); } + if (typeof parsed.threadListV2Enabled === "boolean") { + preferences.threadListV2Enabled = parsed.threadListV2Enabled; + } return preferences; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d369fbc4377..80bc1916b11 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -58,6 +58,8 @@ function threadDetailToShell( createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1c0d34ea5bc..01567b98d32 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -136,6 +136,7 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + threadSettlement: true, }, }; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index b00c00e0d3f..731002ea783 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -146,6 +146,8 @@ describe("OrchestrationEngine", () => { createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f..a9d7317999a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -171,6 +171,70 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { for (const row of stateRows) { assert.equal(row.lastAppliedSequence, 3); } + + // Settled lifecycle through the DB pipeline: thread.settled writes the + // override + timestamp, thread.unsettled(user) flips to the active pin. + yield* eventStore.append({ + type: "thread.settled", + eventId: EventId.make("evt-settle-1"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: CommandId.make("cmd-settle-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-settle-1"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + settledAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const settledRows = yield* sql<{ + readonly settledOverride: string | null; + readonly settledAt: string | null; + }>` + SELECT + settled_override AS "settledOverride", + settled_at AS "settledAt" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepEqual(settledRows, [ + { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" }, + ]); + + yield* eventStore.append({ + type: "thread.unsettled", + eventId: EventId.make("evt-unsettle-1"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: CommandId.make("cmd-unsettle-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-unsettle-1"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + reason: "user", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const unsettledRows = yield* sql<{ + readonly settledOverride: string | null; + readonly settledAt: string | null; + }>` + SELECT + settled_override AS "settledOverride", + settled_at AS "settledAt" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]); }), ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..3ceae0ea43b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -607,6 +607,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -645,6 +647,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.settled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsettled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9a136b06872..15ded458e22 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -308,6 +308,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [ { @@ -418,6 +420,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", @@ -562,6 +566,115 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("keeps settled threads in the shell snapshot with non-null settlement fields", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-settled-test', + 'Settled Test', + '/tmp/settled-test', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-06T00:00:00.000Z', + '2026-04-06T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + settled_override, + settled_at, + deleted_at + ) + VALUES ( + 'thread-settled', + 'project-settled-test', + 'Settled Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:02.000Z', + '2026-04-06T00:00:05.000Z', + NULL, + 'settled', + '2026-04-06T00:00:04.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z') + `; + + // Settled ≠ archived: the thread must appear in the LIVE shell + // snapshot, carrying its settlement fields through the row aliases. + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.deepEqual( + shellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-settled")], + ); + assert.equal(shellSnapshot.threads[0]?.settledOverride, "settled"); + assert.equal(shellSnapshot.threads[0]?.settledAt, "2026-04-06T00:00:04.000Z"); + + // And the full command read model carries them too. + const readModel = yield* snapshotQuery.getCommandReadModel(); + const thread = readModel.threads.find( + (candidate) => candidate.id === ThreadId.make("thread-settled"), + ); + assert.equal(thread?.settledOverride, "settled"); + assert.equal(thread?.settledAt, "2026-04-06T00:00:04.000Z"); + }), + ); + it.effect( "reads targeted project, thread, and count queries without hydrating the full snapshot", () => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 32210436e67..155e9ab0013 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -334,6 +334,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -362,6 +364,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -392,6 +396,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -754,6 +760,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1186,6 +1194,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1384,6 +1394,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1513,6 +1525,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1647,6 +1661,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1887,6 +1903,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -1981,6 +1999,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..0d0d7bdd5e4 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -4,11 +4,13 @@ import { ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, + ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, + ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -29,11 +31,13 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; +export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; +export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9c6c8bd2a18..9531cd5c3af 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -68,6 +68,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -91,6 +93,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts new file mode 100644 index 00000000000..73f1cbf9127 --- /dev/null +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -0,0 +1,521 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationSession, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const SETTLED_AT = "2025-12-30T00:00:00.000Z"; + +function makeReadModel( + settledOverride: OrchestrationThread["settledOverride"], + archivedAt: string | null = null, + session: OrchestrationSession | null = null, + activities: OrchestrationThread["activities"] = [], + messages: OrchestrationThread["messages"] = [], +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + settledOverride, + settledAt: settledOverride === "settled" ? SETTLED_AT : null, + deletedAt: null, + messages, + proposedPlans: [], + activities, + checkpoints: [], + session, + }, + ], + updatedAt: NOW, + }; +} + +function makeSession(status: OrchestrationSession["status"]): OrchestrationSession { + return { + threadId: ThreadId.make("thread-1"), + status, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("settles active threads and re-emits idempotently for settled ones", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.settled"); + if (events[0]?.type === "thread.settled") { + expect(events[0].payload.settledAt).toBe(events[0].payload.updatedAt); + } + + // Already settled: the engine rejects zero-event commands, so idempotency + // is by re-emission — preserving the original settledAt. + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel("settled"), + }); + const reEmitEvents = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(reEmitEvents).toHaveLength(1); + expect(reEmitEvents[0]?.type).toBe("thread.settled"); + if (reEmitEvents[0]?.type === "thread.settled") { + expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); + // updatedAt must NOT rewind to the historical settledAt: sorting and + // relative-time labels key on it. + expect(reEmitEvents[0].payload.updatedAt).not.toBe(SETTLED_AT); + } + }), + ); + + it.effect("rejects settling a thread with a live session", () => + Effect.gen(function* () { + for (const status of ["starting", "running"] as const) { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make(`cmd-settle-live-${status}`), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession(status)), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + } + // Stopped/error sessions are settleable — only live work is protected. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-stopped"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession("stopped")), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + }), + ); + + it.effect("rejects settling a thread with an open approval or user-input request", () => + Effect.gen(function* () { + const requestActivity = (kind: string, requestId: string, at: string) => + ({ + id: EventId.make(`activity-${requestId}-${kind}`), + tone: "approval" as const, + kind, + summary: kind, + payload: { requestId }, + turnId: null, + createdAt: at, + }) as OrchestrationThread["activities"][number]; + + // Open approval request: settle rejected. + const openError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pending"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("approval.requested", "req-1", NOW), + ]), + }).pipe(Effect.flip); + expect(openError._tag).toBe("OrchestrationCommandInvariantError"); + + // Same request later resolved: settleable again. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-resolved"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("approval.requested", "req-1", NOW), + requestActivity("approval.resolved", "req-1", NOW), + ]), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + + // Open user-input request: also rejected. + const inputError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pending-input"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("user-input.requested", "req-2", NOW), + ]), + }).pipe(Effect.flip); + expect(inputError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("clears an open request when its respond failure marks it stale", () => + Effect.gen(function* () { + const activity = ( + kind: string, + requestId: string, + payload: Record, + ): OrchestrationThread["activities"][number] => + ({ + id: EventId.make(`activity-${requestId}-${kind}`), + tone: "approval" as const, + kind, + summary: kind, + payload: { requestId, ...payload }, + turnId: null, + createdAt: NOW, + }) as OrchestrationThread["activities"][number]; + + // Stale-failure detail clears the request — mirrors the projection's + // pending accounting, which is what the client's canSettle sees. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-stale-failed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + activity("approval.requested", "req-1", {}), + activity("provider.approval.respond.failed", "req-1", { + detail: "Unknown pending approval request req-1", + }), + activity("user-input.requested", "req-2", {}), + activity("provider.user-input.respond.failed", "req-2", { + detail: "stale pending user-input request req-2", + }), + ]), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + + // A non-stale respond failure (transient provider error) keeps the + // request open: the user can retry, so it is still blocked-on-you. + const stillOpen = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-transient-failed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + activity("approval.requested", "req-3", {}), + activity("provider.approval.respond.failed", "req-3", { + detail: "provider connection reset", + }), + ]), + }).pipe(Effect.flip); + expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("bounds the queued-turn grace window against client clock skew", () => + Effect.gen(function* () { + const userMessage = (createdAt: string): OrchestrationThread["messages"][number] => ({ + id: MessageId.make("message-queued"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }); + + // The decider's clock is the Effect test clock, pinned to the epoch: + // timestamps here are relative to 1970-01-01T00:00:00.000Z. + + // Within the grace window: genuinely queued, settle rejected. + const queuedError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-queued"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]), + }).pipe(Effect.flip); + expect(queuedError._tag).toBe("OrchestrationCommandInvariantError"); + + // Message timestamp far in the FUTURE (client clock ahead of server): + // a negative age must not read as queued forever — past the grace + // bound in either direction the thread is settleable. + const skewed = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-skewed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [], [userMessage("1970-01-01T01:00:00.000Z")]), + }); + const skewedEvents = Array.isArray(skewed) ? skewed : [skewed]; + expect(skewedEvents[0]?.type).toBe("thread.settled"); + }), + ); + + it.effect("rejects settling and unsettling archived threads", () => + Effect.gen(function* () { + const settleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-archived"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, NOW), + }).pipe(Effect.flip); + expect(settleError._tag).toBe("OrchestrationCommandInvariantError"); + + const unsettleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-archived"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled", NOW), + }).pipe(Effect.flip); + expect(unsettleError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("maps unsettle reasons to overrides and re-emits idempotently", () => + Effect.gen(function* () { + const userEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled"), + }); + const userEvents = Array.isArray(userEvent) ? userEvent : [userEvent]; + expect(userEvents).toHaveLength(1); + expect(userEvents[0]?.type).toBe("thread.unsettled"); + if (userEvents[0]?.type === "thread.unsettled") { + expect(userEvents[0].payload.reason).toBe("user"); + } + + // Re-dispatching against the already-reached state re-emits rather than + // producing zero events (the engine rejects empty commands). + const userAgain = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user-again"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("active"), + }); + const userAgainEvents = Array.isArray(userAgain) ? userAgain : [userAgain]; + expect(userAgainEvents).toHaveLength(1); + expect(userAgainEvents[0]?.type).toBe("thread.unsettled"); + }), + ); + + it.effect("prepends activity unsets for turn starts and live session updates", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const sessionResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set"), + threadId: ThreadId.make("thread-1"), + session: makeSession("running"), + createdAt: NOW, + }, + // A keep-active pin is also an override: real activity clears it + // back to neutral so auto-settle can apply again later. + readModel: makeReadModel("active"), + }); + const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; + expect(sessionEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.session-set", + ]); + }), + ); + + it.effect("clears a keep-active pin on real activity", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-active-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-active"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + // The pin exists to suppress AUTO-settle, not to survive real work: + // activity resets it to neutral, restoring the default lifecycle. + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const activityResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-active-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-active"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult]; + expect(activityEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + }), + ); + + it.effect("does not unsettle for session stop/error status writes", () => + Effect.gen(function* () { + for (const status of ["stopped", "error", "ready", "idle"] as const) { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make(`cmd-session-${status}`), + threadId: ThreadId.make("thread-1"), + session: makeSession(status), + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.session-set"]); + } + }), + ); + + it.effect("unsettles for approval and user-input activities but not others", () => + Effect.gen(function* () { + const approvalResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-1"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const approvalEvents = Array.isArray(approvalResult) ? approvalResult : [approvalResult]; + expect(approvalEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + + const routineResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-routine"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-2"), + tone: "info", + kind: "tool.completed", + summary: "Tool completed", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const routineEvents = Array.isArray(routineResult) ? routineResult : [routineResult]; + expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 1730494ecc6..cba967afc7c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -24,6 +24,61 @@ import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +// Session adoption takes seconds; a user message still unadopted after this +// window is a failed/stale start, not pending work. Mirrors the client's +// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. +const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +/** + * Blocked-on-you work derived from the thread's retained activities: an + * approval or user-input request with no later resolution for the same + * requestId. The server-side twin of the shell's hasPendingApprovals / + * hasPendingUserInput flags, which the decider read model does not carry. + * The clearing rules MUST match ProjectionPipeline's pending accounting — + * resolved activities always clear, respond.failed clears only when the + * failure detail marks the request stale/unknown — or settle would be + * rejected on threads whose shell flags read as clear. + */ +function isStaleRequestFailureDetail(payload: Record | null): boolean { + const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + if (detail === null) return false; + return ( + detail.includes("stale pending approval request") || + detail.includes("unknown pending approval request") || + detail.includes("unknown pending permission request") || + detail.includes("stale pending user-input request") || + detail.includes("unknown pending user-input request") || + detail.includes("unknown pending user input request") || + detail.includes("unknown pending codex user input request") + ); +} + +function hasOpenBlockingRequest(thread: { + readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; +}): boolean { + const openRequestIds = new Set(); + for (const activity of thread.activities) { + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + const requestId = typeof payload?.requestId === "string" ? payload.requestId : null; + if (requestId === null) continue; + if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { + openRequestIds.add(requestId); + } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") { + openRequestIds.delete(requestId); + } else if ( + (activity.kind === "provider.approval.respond.failed" || + activity.kind === "provider.user-input.respond.failed") && + isStaleRequestFailureDetail(payload) + ) { + openRequestIds.delete(requestId); + } + } + return openRequestIds.size > 0; +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -327,6 +382,132 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.settle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Server-side twin of the client's canSettle session check: a stale + // or raced client must not settle a thread whose session is coming + // alive or working. + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has an active session and cannot be settled`, + }), + ); + } + // Pending approval / user-input requests are blocked-on-you work: a + // raced or stale client must not park them behind a settled override + // that would surface only after the request resolves. + if (hasOpenBlockingRequest(thread)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be settled`, + }), + ); + } + const occurredAt = yield* nowIso; + // A queued turn start — a user message no turn has picked up yet — is + // work in flight even though session is still null (turn.start emits + // message-sent + turn-start-requested; the session arrives later). + // Settling in that window would hide just-requested work. Detection + // mirrors the client's hasQueuedTurnStart: the newest user message is + // strictly newer than every latestTurn timestamp (adoption stamps the + // new turn's requestedAt with the message time, clearing this), and + // only within the adoption grace window — historical threads whose + // last user message postdates their turn timestamps (older-server + // data, mid-turn messages) must stay settleable. A failed session + // start (status "error") clears the block immediately. + const latestUserMessageAtMs = thread.messages.reduce( + (latest, message) => + message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, + Number.NEGATIVE_INFINITY, + ); + const latestTurnAtMs = + thread.latestTurn === null + ? Number.NEGATIVE_INFINITY + : Math.max( + ...[ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].map((candidate) => + candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), + ), + ); + // The age check is bounded on BOTH sides: message timestamps are + // client-supplied, so a client clock ahead of the server yields a + // negative age. Without the lower bound that negative age satisfies + // `<= grace` for as long as the skew lasts, extending the settle + // block far past the intended two minutes. + const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; + const hasQueuedTurnStart = + thread.session?.status !== "error" && + Number.isFinite(latestUserMessageAtMs) && + latestUserMessageAtMs > latestTurnAtMs && + Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS; + if (hasQueuedTurnStart) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be settled`, + }), + ); + } + // Settling an already-settled thread re-emits with the original + // settledAt: the engine rejects zero-event commands, and bulk-settle / + // double-click must stay silent no-ops rather than surface errors. + const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt: alreadySettled ? thread.settledAt : occurredAt, + // A re-emission is a projected no-op: keep the existing updatedAt + // so duplicate settles neither rewind nor churn ordering. A fresh + // settle stamps the command time. + updatedAt: alreadySettled ? thread.updatedAt : occurredAt, + }, + }; + } + + case "thread.unsettle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): reducing the event a + // second time lands on the same override state. A re-emission keeps + // the existing updatedAt so duplicates do not churn ordering. + const alreadyPinnedActive = thread.settledOverride === "active"; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: alreadyPinnedActive ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -479,7 +660,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - return [userMessageEvent, turnStartRequestedEvent]; + // Real activity resets ANY override: it wakes an explicitly settled + // thread, and it clears a keep-active pin back to neutral so the + // thread can auto-settle again after this burst of work goes stale. + if (targetThread.settledOverride === null) { + return [userMessageEvent, turnStartRequestedEvent]; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -600,12 +801,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); - return { + const sessionSetEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -619,6 +820,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" session: command.session, }, }; + // Only a session coming alive is activity worth waking a settled thread + // for — status writes like ready/stopped/error arrive after the fact and + // must not fight a user's explicit settle. + const isSessionActivity = + command.session.status === "starting" || command.session.status === "running"; + // Real activity resets ANY override (settled wakes, active unpins). + if (thread.settledOverride === null || !isSessionActivity) { + return sessionSetEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, sessionSetEvent]; } case "thread.message.assistant.delta": { @@ -745,7 +970,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.activity.append": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, @@ -758,7 +983,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? ((command.activity.payload as { requestId: string }) .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; - return { + const activityAppendedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -772,6 +997,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" activity: command.activity, }, }; + // An approval or user-input request is blocked-on-you work — it must + // never stay hidden inside a settled slim row. + const wakesSettledThread = + command.activity.kind === "approval.requested" || + command.activity.kind === "user-input.requested"; + // Real activity resets ANY override (settled wakes, active unpins). + if (thread.settledOverride === null || !wakesSettledThread) { + return activityAppendedEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, activityAppendedEvent]; } default: { diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts new file mode 100644 index 00000000000..2070c44418a --- /dev/null +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -0,0 +1,88 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects settled lifecycle events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const settled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.settled", + payload: { threadId: ThreadId.make("thread-1"), settledAt: now, updatedAt: now }, + }), + ); + expect(settled.threads[0]?.settledOverride).toBe("settled"); + expect(settled.threads[0]?.settledAt).toBe(now); + + const userUnsettled = yield* projectEvent( + settled, + makeEvent({ + sequence: 3, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + }), + ); + expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); + expect(userUnsettled.threads[0]?.settledAt).toBeNull(); + + const activityUnsettled = yield* projectEvent( + userUnsettled, + makeEvent({ + sequence: 4, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + }), + ); + expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); + expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd5078026..e9a55c0796b 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -89,6 +89,8 @@ describe("orchestration projector", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..c8f47dcebbf 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -22,7 +22,9 @@ import { ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, + ThreadSettledPayload, ThreadUnarchivedPayload, + ThreadUnsettledPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -286,6 +288,8 @@ export function projectEvent( createdAt: payload.createdAt, updatedAt: payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], activities: [], @@ -337,6 +341,30 @@ export function projectEvent( })), ); + case "thread.settled": + return decodeForEvent(ThreadSettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: "settled", + settledAt: payload.settledAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsettled": + return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14..497aa8b7d2c 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -91,6 +91,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -128,4 +130,58 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }); }), ); + + it.effect("round-trips non-null settlement values through the thread row", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + + yield* threads.upsert({ + threadId: ThreadId.make("thread-settled"), + projectId: ProjectId.make("project-1"), + title: "Settled thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-25T00:00:00.000Z", + archivedAt: null, + settledOverride: "settled", + settledAt: "2026-03-25T00:00:00.000Z", + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + + const persisted = yield* threads.getById({ + threadId: ThreadId.make("thread-settled"), + }); + const row = Option.getOrNull(persisted); + if (!row) { + return yield* Effect.die("Expected settled projection_threads row to exist."); + } + assert.strictEqual(row.settledOverride, "settled"); + assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); + + // Un-settle to the keep-active pin and confirm the flip persists. + yield* threads.upsert({ + ...row, + settledOverride: "active", + settledAt: null, + }); + const repersisted = yield* threads.getById({ + threadId: ThreadId.make("thread-settled"), + }); + const updated = Option.getOrNull(repersisted); + assert.strictEqual(updated?.settledOverride, "active"); + assert.strictEqual(updated?.settledAt, null); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..e0c85e91494 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -43,6 +43,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + settled_override, + settled_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -62,6 +64,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.settledOverride}, + ${row.settledAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -81,6 +85,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + settled_override = excluded.settled_override, + settled_at = excluded.settled_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -107,6 +113,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -135,6 +143,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d468838d5d4..cacb2c85b83 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ProjectionThreadsSettled", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts new file mode 100644 index 00000000000..e93407defe2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "settled_override")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_override TEXT + `; + } + + if (!columns.some((column) => column.name === "settled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..8057d434950 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -36,6 +36,8 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), + settledAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 166a88ef17b..3843c8acbcd 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -100,6 +100,8 @@ function makeReadModel( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 0c261c8f21e..74a4de594a1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -305,6 +305,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -451,6 +453,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", @@ -607,6 +611,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6122c498145..8a5e0b713e6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -164,6 +164,8 @@ const makeDefaultOrchestrationReadModel = () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -193,6 +195,8 @@ const makeDefaultOrchestrationThreadShell = ( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -5499,6 +5503,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6c692dc3de8..8a713fd9026 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -8,7 +8,9 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; +import { useClientSettings } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; +import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { resolveInitialThreadSidebarWidth, @@ -98,7 +100,12 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + // Settings routes render the settings nav, which lives in the v1 component + // and is identical for both sidebars — so v1 stays mounted there. const pathname = useLocation({ select: (location) => location.pathname }); + const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + const useSidebarV2 = sidebarV2Enabled && !isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); @@ -157,7 +164,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { - + {useSidebarV2 ? : } {children} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bc7487cee29..c578e69c450 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -49,6 +49,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 325f9afa90a..6b74eae9ff4 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -45,6 +45,8 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, updatedAt: draftThread.createdAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: draftThread.branch, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..f3145df4500 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5220,6 +5220,7 @@ function ChatViewContent(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} activeProjectName={activeProject?.title} + activeProjectCwd={activeProject?.workspaceRoot ?? null} openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..6609017f91c 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -24,6 +24,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-01T00:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 1dd4e340125..a9b32a887c0 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -18,8 +18,10 @@ import { resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, + resolveSidebarV2Status, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, + sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, @@ -634,6 +636,100 @@ describe("isContextMenuPointerDown", () => { }); }); +describe("resolveSidebarV2Status", () => { + const session = { + threadId: ThreadId.make("thread-1"), + status: "running" as const, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-1" as never, + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }; + + const idle = { hasPendingApprovals: false, hasPendingUserInput: false }; + + it("prioritizes approval over a running session", () => { + expect(resolveSidebarV2Status({ ...idle, hasPendingApprovals: true, session })).toBe( + "approval", + ); + }); + + it("prioritizes awaiting input over a running session, below approval", () => { + expect(resolveSidebarV2Status({ ...idle, hasPendingUserInput: true, session })).toBe("input"); + expect( + resolveSidebarV2Status({ + ...idle, + hasPendingApprovals: true, + hasPendingUserInput: true, + session, + }), + ).toBe("approval"); + }); + + it("reports working for running and starting sessions", () => { + expect(resolveSidebarV2Status({ ...idle, session })).toBe("working"); + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "starting" as const }, + }), + ).toBe("working"); + }); + + it("reports failed only while the session status is error", () => { + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "error" as const, lastError: "boom" }, + }), + ).toBe("failed"); + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "stopped" as const, lastError: "persisted" }, + }), + ).toBe("ready"); + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "ready" as const, lastError: "persisted" }, + }), + ).toBe("ready"); + }); + + it("defaults to ready with no session", () => { + expect(resolveSidebarV2Status({ ...idle, session: null })).toBe("ready"); + }); +}); + +describe("sortThreadsForSidebarV2", () => { + const sortable = (input: { id: string; createdAt: string }) => ({ + id: input.id, + createdAt: input.createdAt, + }); + + it("orders by creation time, newest first, ignoring activity", () => { + const sorted = sortThreadsForSidebarV2([ + sortable({ id: "oldest", createdAt: "2026-03-09T08:00:00.000Z" }), + sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), + sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); + }); + + it("breaks creation-time ties by id so the order is stable", () => { + const sorted = sortThreadsForSidebarV2([ + sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }), + sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { hasActionableProposedPlan: false, @@ -900,6 +996,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1a565efd878..b8ae19c3e5f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -417,6 +417,71 @@ export function resolveThreadRowClassName(input: { return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); } +// ── Sidebar v2 status model ───────────────────────────────────────── +// Five visual states, three colors: color is reserved for "act now" +// (approval), "in motion" (working), and "broken" (failed). Ready is the +// unlabeled resting state — the agent stopped and is waiting on the user, +// whether it finished, asked a question, or proposed a plan. +// Unread completion is tracked separately: it describes whether a ready +// thread needs attention, not what the thread is currently doing. +export type SidebarV2Status = "approval" | "input" | "working" | "failed" | "ready"; + +type SidebarV2StatusInput = Pick< + SidebarThreadSummary, + "hasPendingApprovals" | "hasPendingUserInput" | "session" +>; + +export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status { + if (thread.hasPendingApprovals) { + return "approval"; + } + if (thread.hasPendingUserInput) { + return "input"; + } + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + if (thread.session?.status === "error") { + return "failed"; + } + return "ready"; +} + +/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not + poison the whole ordering, so it sinks to the epoch instead. */ +export function parseTimestampMs(isoDate: string): number { + const parsed = Date.parse(isoDate); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** First VALID timestamp wins: `a ?? b` falls through on null, but a present- + yet-malformed string must also fall through to the next candidate rather + than sink the row to the epoch. */ +export function firstValidTimestampMs( + ...candidates: ReadonlyArray +): number { + for (const candidate of candidates) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +// v2 sort: static creation order, newest thread on top. Activity NEVER +// reorders the list — a row holds its position from open until settled, so +// the screen only moves at lifecycle transitions. Status (including pending +// approval) is carried by each card's edge strip, not by position. +export function sortThreadsForSidebarV2< + T extends { readonly id: string; readonly createdAt: string }, +>(threads: readonly T[]): T[] { + return [...threads].toSorted( + (left, right) => + parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + left.id.localeCompare(right.id), + ); +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; }): ThreadStatusPill | null { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4b4b1e52a94..cf3fa30cf8d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -8,7 +8,6 @@ import { Globe2Icon, LoaderIcon, SearchIcon, - SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, @@ -63,7 +62,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -74,10 +73,9 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { cn, isMacPlatform } from "../lib/utils"; +import { isMacPlatform } from "../lib/utils"; import { readThreadShell, useProject, @@ -126,7 +124,6 @@ import { import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, @@ -169,9 +166,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./u import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, - SidebarFooter, SidebarGroup, - SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -179,7 +174,6 @@ import { SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, - SidebarTrigger, useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; @@ -194,7 +188,6 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, - resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -204,12 +197,12 @@ import { ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; -import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -223,7 +216,6 @@ import { type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; -import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -2787,112 +2779,6 @@ function SortableProjectItem({ ); } -const SidebarChromeHeader = memo(function SidebarChromeHeader({ - isElectron, -}: { - isElectron: boolean; -}) { - const stageLabel = useSidebarStageLabel(); - const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel); - - return ( - - {backdropVariant ? : null} - - - - ); -}); - -function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { - return ( - - - - Code - - - ); -} - -function useSidebarStageLabel() { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - - return resolveSidebarStageBadgeLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }); -} - -function T3Wordmark() { - return ( - - - - ); -} - -const SidebarChromeFooter = memo(function SidebarChromeFooter() { - const navigate = useNavigate(); - const { isMobile, setOpenMobile } = useSidebar(); - const handleSettingsClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/settings" }); - }, [isMobile, navigate, setOpenMobile]); - - return ( - - - - - - - - Settings - - - - - ); -}); - interface SidebarProjectsContentProps { showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx new file mode 100644 index 00000000000..a62e09a3b8e --- /dev/null +++ b/apps/web/src/components/SidebarV2.tsx @@ -0,0 +1,1707 @@ +import { autoAnimate } from "@formkit/auto-animate"; +import { useAtomValue } from "@effect/atom-react"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + scopeProjectRef, + scopeThreadRef, + scopedThreadKey, +} from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { + CheckIcon, + CircleCheckIcon, + CircleDashedIcon, + CloudIcon, + FolderPlusIcon, + PlusIcon, + SearchIcon, + SquarePenIcon, + Undo2Icon, +} from "lucide-react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useParams, useRouter } from "@tanstack/react-router"; + +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { isElectron } from "../env"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHintsForModifiers, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; +import { useShortcutModifierState } from "../shortcutModifierState"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { isMacPlatform } from "~/lib/utils"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { readLocalApi } from "../localApi"; +import { useUiStateStore } from "../uiStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; +import { useThreadActions } from "../hooks/useThreadActions"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { onOpenNewThreadPicker } from "../newThreadPickerBus"; +import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; +import { + resolveThreadActionProjectRef, + startNewThreadFromContext, + startNewThreadInProjectFromContext, +} from "../lib/chatThreadActions"; +import { useClientSettings } from "../hooks/useSettings"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useProjects, useThreadShells } from "../state/entities"; +import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { vcsEnvironment } from "../state/vcs"; +import { threadEnvironment } from "../state/threads"; +import { useEnvironmentQuery } from "../state/query"; +import { useAtomCommand } from "../state/use-atom-command"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import type { SidebarThreadSummary } from "../types"; +import { cn } from "~/lib/utils"; +import { + firstValidTimestampMs, + hasUnseenCompletion, + isTrailingDoubleClick, + resolveAdjacentThreadId, + resolveSidebarV2Status, + sortThreadsForSidebarV2, +} from "./Sidebar.logic"; +import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; +import { primaryServerProvidersAtom } from "../state/server"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { CommandDialogTrigger } from "./ui/command"; +import { Kbd } from "./ui/kbd"; +import { + SidebarContent, + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, + useSidebar, +} from "./ui/sidebar"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +// Settled-tail paging: recent history is the common lookup; the deep tail +// stays behind an explicit Show more. +const SETTLED_TAIL_INITIAL_COUNT = 10; +const SETTLED_TAIL_PAGE_COUNT = 25; + +function compactSidebarTimeLabel(label: string): string { + if (label === "just now") return "now"; + return label.endsWith(" ago") ? label.slice(0, -4) : label; +} + +function threadTimeLabel(thread: SidebarThreadSummary): string { + const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; + return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); +} + +const SidebarV2Row = memo(function SidebarV2Row(props: { + thread: SidebarThreadSummary; + variant: "card" | "slim"; + // Slim rows are either settled (action: un-settle) or merely quiet + // (seen Ready threads — action: settle). + variantAction: "settle" | "unsettle"; + // False on environments whose server predates thread.settle/unsettle: + // the lifecycle affordances hide entirely rather than fail on click. + settlementSupported: boolean; + // Marks where active work transitions into history: a quiet labeled + // rule above the first settled row, so the tail reads as a named zone + // rather than an unexplained gap. + showSettledGap?: boolean; + isActive: boolean; + jumpLabel: string | null; + currentEnvironmentId: string | null; + environmentLabel: string | null; + projectCwd: string | null; + projectTitle: string | null; + providerEntryByInstanceId: ReadonlyMap; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + isRenaming: boolean; + renamingTitle: string; + onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onSettle: (threadRef: ScopedThreadRef) => void; + onUnsettle: (threadRef: ScopedThreadRef) => void; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; +}) { + const { + isRenaming, + onChangeRequestState, + onCancelRename, + onCommitRename, + onContextMenu, + onRenameTitleChange, + onSettle, + onStartRename, + onThreadActivate, + onThreadClick, + onUnsettle, + renamingTitle, + thread, + variant, + variantAction, + } = props; + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const openPrLink = useOpenPrLink(); + + // Same semantics as v1 (never-visited counts as read): flipping the beta + // flag must not light up every historical thread as unread. + const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); + const status = resolveSidebarV2Status(thread); + const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected; + const topStatus = + status === "working" + ? { + label: "Working", + icon: "working" as const, + className: + "animate-sidebar-working-text font-semibold text-blue-600 motion-reduce:animate-none dark:text-blue-400", + } + : status === "approval" + ? { + label: "Approval", + icon: null, + className: "font-semibold text-amber-700 dark:text-amber-300", + } + : status === "input" + ? { + label: "Input", + icon: null, + className: "font-semibold text-amber-700 dark:text-amber-300", + } + : status === "failed" + ? { + label: "Failed", + icon: null, + className: "font-semibold text-red-700 dark:text-red-300", + } + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "font-semibold text-emerald-700 dark:text-emerald-300", + } + : null; + + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + // Report the PR state up: the parent partitions rows with effectiveSettled, + // and a merged/closed PR auto-settles a thread — data only rows have. + const prState = pr?.state ?? null; + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const driverKind = props.providerEntryByInstanceId.get(modelInstanceId)?.driverKind ?? null; + + const isRemote = + props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onThreadClick(event, threadRef); + }, + [onThreadClick, threadRef], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); + }, + [onContextMenu, threadRef], + ); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onThreadActivate(threadRef); + }, + [onThreadActivate, threadRef], + ); + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + if ((event.target as HTMLElement).closest("button, a, input")) return; + event.preventDefault(); + onStartRename(threadRef, thread.title); + }, + [isRenaming, onStartRename, thread.title, threadRef], + ); + const renameCommittedRef = useRef(false); + useEffect(() => { + if (isRenaming) renameCommittedRef.current = false; + }, [isRenaming]); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renameCommittedRef.current = true; + onCommitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renameCommittedRef.current = true; + onCancelRename(); + } + }, + [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], + ); + const handleRenameBlur = useCallback(() => { + if (!renameCommittedRef.current) { + onCommitRename(threadRef, renamingTitle, thread.title); + } + }, [onCommitRename, renamingTitle, thread.title, threadRef]); + const handleSettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onSettle(threadRef); + }, + [onSettle, threadRef], + ); + const handleUnsettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnsettle(threadRef); + }, + [onUnsettle, threadRef], + ); + const handlePrClick = useCallback( + (event: ReactMouseEvent) => { + if (pr?.url) openPrLink(event, pr.url); + }, + [openPrLink, pr], + ); + + const rowClassName = cn( + "group/v2-row relative w-full cursor-pointer select-none rounded-md text-left", + props.isActive + ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]" + : isSelected + ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]" + : "hover:bg-accent/65", + ); + + const title = isRenaming ? ( + onRenameTitleChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-foreground" + /> + ) : ( + + {thread.title} + + ); + + const prBadge = + prStatus && pr ? ( + + ) : null; + + if (variant === "slim") { + return ( +
  • + {props.showSettledGap ? ( +
    + Settled + +
    + ) : null} +
    + {/* Settled history recedes: dimmed favicon at rest, restored on + hover so the tail stays scannable when you're hunting. */} + + + + {title} + {/* The PR badge stays outside the hover-fading slot: it must + remain visible AND clickable while the row is hovered. Only + the time/jump label yields to the settle affordance. */} + {prBadge} + + + + {props.jumpLabel ?? + compactSidebarTimeLabel( + formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt), + )} + + + {!props.settlementSupported ? null : variantAction === "unsettle" ? ( + + ) : ( + + )} + +
    +
  • + ); + } + + const diff = latestTurnDiff(thread); + + return ( +
  • +
    +
    +
    + + {props.projectTitle ? ( + + {props.projectTitle} + + ) : ( + + )} + + + {props.jumpLabel ? ( + props.jumpLabel + ) : topStatus ? ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {topStatus.label} + + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported ? ( + + ) : null} + +
    +
    {title}
    +
    + {thread.branch ? ( + + {thread.branch} + + ) : ( + + )} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + {driverKind ? ( + + } + > + + + {thread.modelSelection.model} + + ) : null} + {isRemote ? ( + + + } + > + + + + Running on {props.environmentLabel ?? "a remote environment"} + + + ) : null} + +
    + {status === "failed" && thread.session?.lastError ? ( +
    + {thread.session.lastError} +
    + ) : null} +
    +
    +
  • + ); +}); + +function latestTurnDiff( + thread: SidebarThreadSummary, +): { insertions: number; deletions: number } | null { + // Shells don't carry checkpoint summaries; diff stats render only when the + // shell projection grows them. Kept as a seam so the row layout is ready. + void thread; + return null; +} + +export default function SidebarV2() { + const projects = useProjects(); + const threads = useThreadShells(); + const router = useRouter(); + const { isMobile, setOpenMobile } = useSidebar(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const { settleThread, unsettleThread, deleteThread } = useThreadActions(); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const newThreadContext = useHandleNewThread(); + const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); + const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); + const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + // Post-settle navigation validates against the CURRENT route, not the one + // captured when the settle started: if the user navigated elsewhere while + // the command was in flight, completing it must not yank them away. + const routeThreadKeyRef = useRef(routeThreadKey); + routeThreadKeyRef.current = routeThreadKey; + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const providerEntryByInstanceId = useMemo( + () => + new Map( + deriveProviderInstanceEntries(serverProviders).map( + (entry) => [entry.instanceId as string, entry] as const, + ), + ), + [serverProviders], + ); + const projectCwdByKey = useMemo( + () => + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.workspaceRoot, + ]), + ), + [projects], + ); + const projectTitleByKey = useMemo( + () => + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); + + // now is quantized to the minute so effectiveSettled memoization doesn't + // churn on every render; auto-settle thresholds are day-granular anyway. + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + const id = window.setInterval( + () => setNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + return () => window.clearInterval(id); + }, []); + + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + + // Project scope: chips above the list. Scoping filters the list AND + // becomes the new-thread target — one visible control doing both jobs the + // old per-project headers did. + const [projectScopeKey, setProjectScopeKey] = useState(null); + const scopedProject = useMemo( + () => + projectScopeKey === null + ? null + : (projects.find( + (project) => `${project.environmentId}:${project.id}` === projectScopeKey, + ) ?? null), + [projectScopeKey, projects], + ); + useEffect(() => { + if ( + projectScopeKey !== null && + !projects.some((project) => `${project.environmentId}:${project.id}` === projectScopeKey) + ) { + setProjectScopeKey(null); + } + }, [projectScopeKey, projects]); + // Scope flips drop the selection: rows selected under the old scope may be + // hidden now, and bulk actions must never count or touch invisible rows. + useEffect(() => { + clearSelection(); + }, [clearSelection, projectScopeKey]); + + // Settled threads stay in the live shell stream (settled ≠ archived), so + // the partition works directly off live shells: no archived-snapshot + // merging, no optimistic holds. Archived threads remain hidden here — + // archive keeps its original "remove from sidebar" meaning. + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const { activeThreads, settledThreads } = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const visible = threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProject === null || + (thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id)), + ); + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + // Threads on servers without the settlement capability (old server, + // or descriptor not loaded yet) never classify as settled: the user + // could neither un-settle nor pin them, so auto-settling them would + // strand rows in a tail with no working affordances. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } + } + return { + activeThreads: sortThreadsForSidebarV2(active), + settledThreads: settled.toSorted( + (left, right) => + firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - + firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + ), + }; + }, [ + autoSettleAfterDays, + changeRequestStateByKey, + nowMinute, + scopedProject, + serverConfigs, + threads, + ]); + + // The settled tail renders in pages: history shouldn't dominate the + // sidebar, and the common lookups are recent. Expansion resets when the + // filter context changes so a scope/search flip never inherits a deep + // page state. + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); + const settledResetKey = `${projectScopeKey ?? "all"}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); + } + const hiddenSettledCount = Math.max(0, settledThreads.length - settledVisibleCount); + const visibleSettledThreads = useMemo( + () => (hiddenSettledCount > 0 ? settledThreads.slice(0, settledVisibleCount) : settledThreads), + [hiddenSettledCount, settledThreads, settledVisibleCount], + ); + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), + [], + ); + + const orderedThreads = useMemo( + () => [...activeThreads, ...visibleSettledThreads], + [activeThreads, visibleSettledThreads], + ); + const orderedThreadKeys = useMemo( + () => + orderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [orderedThreads], + ); + // Rows call back into the click handler without carrying the ordered list as + // a prop — a fresh array identity per shell update would defeat every row's + // memoization. The ref keeps shift-range-select working against the list as + // rendered at click time. + const orderedThreadKeysRef = useRef(orderedThreadKeys); + orderedThreadKeysRef.current = orderedThreadKeys; + const threadByKey = useMemo( + () => + new Map( + orderedThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [orderedThreads], + ); + // Handlers read these through refs: depending on per-update Map/Set + // identities would give every row a fresh callback prop on each shell + // event and defeat row memoization during streaming. + const threadByKeyRef = useRef(threadByKey); + threadByKeyRef.current = threadByKey; + // handleNewThread is inherently unstable (depends on the projects list); + // a ref keeps it out of attemptSettle's dependency array. + const handleNewThreadRef = useRef(newThreadContext.handleNewThread); + handleNewThreadRef.current = newThreadContext.handleNewThread; + const settledThreadKeys = useMemo( + () => + new Set( + settledThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [settledThreads], + ); + const settledThreadKeysRef = useRef(settledThreadKeys); + settledThreadKeysRef.current = settledThreadKeys; + + const jumpLabelByKey = useMemo(() => { + const mapping = new Map(); + for (const [index, threadKey] of orderedThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(index); + if (!jumpCommand) break; + const label = shortcutLabelForCommand(keybindings, jumpCommand); + if (label) mapping.set(threadKey, label); + } + return mapping; + }, [keybindings, orderedThreadKeys]); + const [showJumpHints, setShowJumpHints] = useState(false); + + // Settled threads are live shells, so opening one is plain navigation: + // history stays readable without un-settling, and sending a message or + // starting a session un-settles server-side. + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], + ); + + const [renamingThreadKey, setRenamingThreadKey] = useState(null); + const [renamingTitle, setRenamingTitle] = useState(""); + const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { + setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingTitle(title); + }, []); + const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); + const commitThreadRename = useCallback( + (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { + void (async () => { + const trimmed = title.trim(); + setRenamingThreadKey(null); + if (trimmed.length === 0) { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (trimmed === originalTitle) return; + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title: trimmed }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [updateThreadMetadata], + ); + + const handleThreadClick = useCallback( + (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const threadKey = scopedThreadKey(threadRef); + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + if (event.shiftKey) { + event.preventDefault(); + rangeSelectTo(threadKey, orderedThreadKeysRef.current); + return; + } + if (isTrailingDoubleClick(event.detail)) { + return; + } + navigateToThread(threadRef); + }, + [navigateToThread, rangeSelectTo, toggleThreadSelection], + ); + + // A settle per thread at a time: double clicks and repeated menu picks + // must not dispatch a second settle that fails and toasts a false error. + const settlingThreadKeysRef = useRef(new Set()); + const attemptSettle = useCallback( + (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + if (settlingThreadKeysRef.current.has(threadKey)) return; + settlingThreadKeysRef.current.add(threadKey); + try { + // Settling the thread you're looking at moves you forward: the next + // remaining card (never a settled row, never one settling in the + // same batch), or a fresh draft in this project when it was the + // last active one. Snapshot the target before the settle mutates + // the partition. Background settles never navigate. + const shell = threadByKeyRef.current.get(threadKey); + let navigateAfterSettle: (() => void) | null = null; + if (routeThreadKey === threadKey) { + const orderedKeys = orderedThreadKeysRef.current; + const settledKeys = settledThreadKeysRef.current; + const currentIndex = orderedKeys.indexOf(threadKey); + const nextCardKey = + currentIndex === -1 + ? null + : ([ + ...orderedKeys.slice(currentIndex + 1), + ...orderedKeys.slice(0, currentIndex), + ].find((key) => !settledKeys.has(key) && !opts.coSettlingKeys?.has(key)) ?? null); + const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; + navigateAfterSettle = nextThread + ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) + : shell + ? () => + void handleNewThreadRef.current( + scopeProjectRef(shell.environmentId, shell.projectId), + ) + : () => void router.navigate({ to: "/" }); + } + const result = await settleThread(threadRef); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not settle. + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + // Only move forward if the user is still on the settled thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSettle?.(); + } + } finally { + settlingThreadKeysRef.current.delete(threadKey); + } + })(); + }, + [navigateToThread, routeThreadKey, router, settleThread], + ); + const attemptUnsettle = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unsettleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [unsettleThread], + ); + + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + // One exact actionable set: keys whose rows are actually rendered + // right now. Selections can outlive their rows (settled-tail paging, + // thread deletion elsewhere) and the menu labels must count only what + // the actions will touch. + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( + (threadKey) => threadByKeyRef.current.has(threadKey), + ); + if (threadKeys.length === 0) return; + const count = threadKeys.length; + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { id: "settle", label: `Settle (${count})` }, + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + if (clicked.value === "settle") { + // Post-settle navigation must skip threads settling in this same + // batch — they are all leaving the card block together. Rows that + // are already explicitly settled are skipped: nothing to do on a + // valid mixed selection. + const coSettlingKeys = new Set(threadKeys); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread || thread.settledOverride === "settled") continue; + attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); + } + clearSelection(); + return; + } + if (clicked.value === "mark-unread") { + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + if (clicked.value !== "delete") return; + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + // Grown as deletions actually land, never seeded with the whole batch: + // orphaned-worktree detection must only discount threads that are + // really gone, or the first delete would treat still-alive batch mates + // as deleted and remove a worktree they still point at. + const deletedThreadKeys = new Set(); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + deletedThreadKeys, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + deletedThreadKeys.add(threadKey); + } + removeFromSelection(threadKeys); + }, + [ + attemptSettle, + clearSelection, + confirmThreadDelete, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + + const handleThreadContextMenu = useCallback( + (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + if (!api) return; + const threadKey = scopedThreadKey(threadRef); + const selectionState = useThreadSelectionStore.getState(); + if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { + await handleMultiSelectContextMenu(position); + return; + } + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) return; + // Un-settle works on every settled row: for explicit settles it + // clears the override, for auto-settled rows it pins the thread + // active until real activity clears the pin. Environments without + // the settlement capability get no lifecycle items at all. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true; + const isSettled = settledThreadKeysRef.current.has(threadKey); + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + ...(supportsSettlement + ? [ + isSettled + ? { id: "unsettle", label: "Un-settle thread" } + : { id: "settle", label: "Settle thread" }, + ] + : []), + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "settle": + attemptSettle(threadRef); + return; + case "unsettle": + attemptUnsettle(threadRef); + return; + case "rename": + startThreadRename(threadRef, thread.title); + return; + case "mark-unread": + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + return; + case "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const result = await deleteThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + return; + } + default: + return; + } + })(); + }, + [ + attemptSettle, + attemptUnsettle, + confirmThreadDelete, + deleteThread, + handleMultiSelectContextMenu, + markThreadUnread, + serverConfigs, + startThreadRename, + ], + ); + + // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as + // v1 — the keybinding layer is shared, only the ordered list differs. + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }, + }); + const navigateToThreadKey = (targetThreadKey: string | null) => { + if (!targetThreadKey) return false; + const targetThread = threadByKey.get(targetThreadKey); + if (!targetThread) return false; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return true; + }; + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + navigateToThreadKey( + resolveAdjacentThreadId({ + threadIds: orderedThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }), + ); + return; + } + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) return; + navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); + }; + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [ + keybindings, + navigateToThread, + orderedThreadKeys, + routeTerminalOpen, + routeThreadKey, + threadByKey, + ]); + + // Same predicate as v1: hints show only while the held modifiers exactly + // match a thread-jump binding. Adding Shift (screenshots) or Alt no + // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. + const shortcutModifiers = useShortcutModifierState(); + const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { platform: navigator.platform }, + ); + useEffect(() => { + setShowJumpHints(shouldShowJumpHintsNow); + }, [shouldShowJumpHintsNow]); + + const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { + if (!node) return; + autoAnimate(node, { duration: 150, easing: "ease-out" }); + }, []); + + // New thread defaults to the project you're in (active thread's project, + // falling back to the top project) — same resolution the command palette + // uses. The chevron menu is the explicit project picker the flat list no + // longer gets from per-project headers. + const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false); + // chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in + // v2 with multiple projects it opens this picker via the event bus. + useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []); + const handleNewThreadClick = useCallback(() => { + // One project: nothing to pick, create immediately. + if (projects.length <= 1) { + if (isMobile) setOpenMobile(false); + void startNewThreadFromContext({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + return; + } + setNewThreadPickerOpen(true); + }, [isMobile, newThreadContext, projects.length, setOpenMobile]); + const createThreadInProject = useCallback( + (environmentId: (typeof projects)[number]["environmentId"], projectId: string) => { + setNewThreadPickerOpen(false); + if (isMobile) setOpenMobile(false); + const project = projects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + if (!project) return; + void startNewThreadInProjectFromContext( + { + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + [isMobile, newThreadContext, projects, setOpenMobile], + ); + const contextualProjectRef = resolveThreadActionProjectRef({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + const newThreadTargetRef = scopedProject + ? scopeProjectRef(scopedProject.environmentId, scopedProject.id) + : contextualProjectRef; + const newThreadTargetProject = newThreadTargetRef + ? (projects.find( + (project) => + project.environmentId === newThreadTargetRef.environmentId && + project.id === newThreadTargetRef.projectId, + ) ?? null) + : null; + // Picker order: the contextual default first (preselected), everything else + // after — the common case is Enter/click on the top row. + const newThreadPickerProjects = useMemo(() => { + if (!newThreadTargetProject) return projects; + return [ + newThreadTargetProject, + ...projects.filter( + (project) => + project.environmentId !== newThreadTargetProject.environmentId || + project.id !== newThreadTargetProject.id, + ), + ]; + }, [newThreadTargetProject, projects]); + + const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); + // Same resolution as v1: prefer the local-thread binding, fall back to + // chat.new, no platform gating — web users have working shortcuts too. + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal") ?? + shortcutLabelForCommand(keybindings, "chat.new"); + const projectScrollerRef = useRef(null); + const [canScrollProjectsRight, setCanScrollProjectsRight] = useState(false); + const updateProjectScrollFade = useCallback(() => { + const scroller = projectScrollerRef.current; + if (!scroller) return; + setCanScrollProjectsRight( + scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, + ); + }, []); + useEffect(() => { + const scroller = projectScrollerRef.current; + if (!scroller) return; + + updateProjectScrollFade(); + const resizeObserver = new ResizeObserver(updateProjectScrollFade); + resizeObserver.observe(scroller); + return () => resizeObserver.disconnect(); + }, [projects, updateProjectScrollFade]); + + return ( + <> + + + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + + + + + + {projects.length > 0 ? ( + +
    +
    + {projects.length > 1 ? ( + + ) : null} + {projects.map((project) => { + const scopeKey = `${project.environmentId}:${project.id}`; + const isScoped = projectScopeKey === scopeKey; + return ( + + ); + })} +
    +
    + + + } + > + + + Add project + +
    +
    +
    + ) : null} + +
      + {orderedThreads.map((thread, threadIndex) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isSettledRow = settledThreadKeys.has(threadKey); + // Settled is the ONLY thing that collapses a row: every + // not-settled thread is a full card. Density comes from users + // (or the auto rules) actually settling work, not from the + // sidebar second-guessing what still matters. + const isCard = !isSettledRow; + const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; + const previousWasCard = + previousThread != null && + !settledThreadKeys.has( + scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), + ); + const showSettledGap = !isCard && previousWasCard; + return ( + + ); + })} + {hiddenSettledCount > 0 ? ( +
    • + +
    • + ) : null} +
    + {orderedThreads.length === 0 ? ( +
    + {projects.length === 0 ? ( + <> + No projects yet + + + ) : scopedProject ? ( + `No threads in ${scopedProject.title} yet` + ) : ( + "No threads yet" + )} +
    + ) : null} +
    +
    + + + + + + New thread in… + +
    { + if ( + event.key !== "ArrowDown" && + event.key !== "ArrowUp" && + event.key !== "Home" && + event.key !== "End" + ) { + return; + } + const container = event.currentTarget; + const options = [...container.querySelectorAll("button")]; + if (options.length === 0) return; + const currentIndex = options.findIndex((option) => option === document.activeElement); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? options.length - 1 + : event.key === "ArrowDown" + ? (currentIndex + 1) % options.length + : (currentIndex - 1 + options.length) % options.length; + event.preventDefault(); + options[nextIndex]?.focus(); + }} + > + {newThreadPickerProjects.map((project, index) => { + const isDefault = index === 0 && newThreadTargetProject !== null; + return ( + + ); + })} + +
    +
    +
    + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f35bff081e1..94e4af3bba6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2143,8 +2143,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ref={composerSurfaceRef} data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} className={cn( - "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-ring/45", - isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border", + "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-foreground/40", + isDragOverComposer + ? "border-primary/70 bg-accent/45" + : "border-black/12 dark:border-white/12", projectSelectionRequired ? "opacity-75" : null, composerProviderState.composerSurfaceClassName, )} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ef3ec863d0b..d39828752fb 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -16,6 +16,7 @@ import ProjectScriptsControl, { } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../state/environments"; +import { ProjectFavicon } from "../ProjectFavicon"; import { cn } from "~/lib/utils"; interface ChatHeaderProps { @@ -24,6 +25,7 @@ interface ChatHeaderProps { draftId?: DraftId; activeThreadTitle: string; activeProjectName: string | undefined; + activeProjectCwd: string | null; openInCwd: string | null; activeProjectScripts: ReadonlyArray | undefined; preferredScriptId: string | null; @@ -58,6 +60,7 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, activeProjectName, + activeProjectCwd, openInCwd, activeProjectScripts, preferredScriptId, @@ -79,6 +82,26 @@ export const ChatHeader = memo(function ChatHeader({ return (
    + {/* The project always leads the header: knowing which project a + thread lives in is priority zero, and the thread title alone + doesn't answer it. */} + {activeProjectName ? ( + + + + + {activeProjectName} + + + + / + + + ) : null} void; +}) { + // Local draft so the field can be emptied mid-edit; the setting only moves + // on valid input and snaps back to the persisted value on blur. + const [draft, setDraft] = useState(String(value)); + useEffect(() => { + setDraft(String(value)); + }, [value]); + + return ( + { + setDraft(event.target.value); + // Number(), not parseInt: "3.5" must be rejected (not truncated to a + // committed 3 while the field shows 3.5) — commit only when the + // persisted value matches the displayed one. + const parsed = Number(event.target.value); + if ( + Number.isInteger(parsed) && + parsed >= AUTO_SETTLE_MIN_DAYS && + parsed <= AUTO_SETTLE_MAX_DAYS + ) { + onCommit(parsed); + } + }} + onBlur={() => setDraft(String(value))} + aria-label="Days of inactivity before auto-settle" + /> + ); +} + +export function BetaSettingsPanel() { + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarAutoSettleAfterDays = useClientSettings( + (settings) => settings.sidebarAutoSettleAfterDays, + ); + const updateSettings = useUpdateClientSettings(); + + return ( + + + updateSettings({ sidebarV2Enabled: Boolean(checked) })} + aria-label="Enable the sidebar v2 beta" + /> + } + /> + {sidebarV2Enabled ? ( + <> + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> + } + /> + {sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } + /> + ) : null} + + ) : null} + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..9d690f2a4e4 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -3,6 +3,7 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, + FlaskConicalIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -28,6 +29,7 @@ export type SettingsSectionPath = | "/settings/providers" | "/settings/source-control" | "/settings/connections" + | "/settings/beta" | "/settings/archived"; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ @@ -40,6 +42,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, + { label: "Beta", to: "/settings/beta", icon: FlaskConicalIcon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx new file mode 100644 index 00000000000..c665f7741e7 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -0,0 +1,127 @@ +import { useAtomValue } from "@effect/atom-react"; +import { SettingsIcon } from "lucide-react"; +import { memo, useCallback } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; + +import { APP_STAGE_LABEL } from "../../branding"; +import { cn } from "../../lib/utils"; +import { primaryServerConfigAtom } from "../../state/server"; +import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic"; +import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; +import { + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarTrigger, + useSidebar, +} from "../ui/sidebar"; +import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; +import { SidebarUpdatePill } from "./SidebarUpdatePill"; + +export const SidebarChromeHeader = memo(function SidebarChromeHeader({ + isElectron, +}: { + isElectron: boolean; +}) { + const stageLabel = useSidebarStageLabel(); + const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel); + + return ( + + {backdropVariant ? : null} + + + + ); +}); + +function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { + return ( + + + + Code + + + ); +} + +function useSidebarStageLabel() { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + + return resolveSidebarStageBadgeLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + +function T3Wordmark() { + return ( + + + + ); +} + +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const handleSettingsClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/settings" }); + }, [isMobile, navigate, setOpenMobile]); + + return ( + + + + + + + + Settings + + + + + ); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index dbde5acce99..de31ff09aa6 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -4,6 +4,7 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -19,7 +20,12 @@ import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; import { readLocalApi } from "../localApi"; -import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; +import { + readEnvironmentSupportsSettlement, + readEnvironmentThreadRefs, + readProject, + readThreadShell, +} from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -39,6 +45,30 @@ export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( + "ThreadSettlementUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support settling yet. Update the server to use Settle."; + } +} + +export class ThreadSettleBlockedError extends Schema.TaggedErrorClass()( + "ThreadSettleBlockedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This thread still needs attention. Resolve or interrupt it first, then try again."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -50,6 +80,12 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); + const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { + reportFailure: false, + }); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + reportFailure: false, + }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -345,6 +381,66 @@ export function useThreadActions() { ], ); + const settleThread = useCallback( + async (target: ScopedThreadRef) => { + // Version skew: never send the command to a server that predates it — + // the raw protocol rejection would read as a random failure. + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettlementUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + const resolved = resolveThreadTarget(target); + // Settle may only target what effectiveSettled could classify as + // settled: not starting/running sessions, not threads waiting on + // approvals or user input. Anything else would hide live work. + if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettleBlockedError({ + environmentId: resolved.threadRef.environmentId, + threadId: resolved.threadRef.threadId, + }), + ), + ); + } + // Settle is a high-frequency lifecycle action and stays silent — no + // toast. + return settleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + }, + [resolveThreadTarget, settleThreadMutation], + ); + + const unsettleThread = useCallback( + async (target: ScopedThreadRef) => { + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettlementUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + // reason "user" pins the thread active: auto-settle (PR merged / + // inactivity) stays suppressed until real activity clears the pin. + return unsettleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }); + }, + [unsettleThreadMutation], + ); + const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); @@ -379,7 +475,16 @@ export function useThreadActions() { unarchiveThread, deleteThread, confirmAndDeleteThread, + settleThread, + unsettleThread, }), - [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + [ + archiveThread, + confirmAndDeleteThread, + deleteThread, + settleThread, + unarchiveThread, + unsettleThread, + ], ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 41abcbd3b17..72caf539e8b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -108,11 +108,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; - /* Duty-cycled indicator animations: long opacity holds with short stepped - ramps, so the compositor only produces frames while the value changes - (~20% of the cycle) instead of every vsync. Keep flat holds dominant. */ + /* Duty-cycled indicator animations: long holds with stepped ramps, so the + compositor updates discrete frames instead of every vsync. */ --animate-status-pulse: status-pulse 2s infinite; --animate-status-ping: status-ping 2s infinite; + --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; --font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; @@ -185,6 +185,21 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil scale: 2; } } + @keyframes sidebar-working-text { + 0%, + 36% { + opacity: 1; + animation-timing-function: steps(10); + } + 50%, + 86% { + opacity: 0.75; + animation-timing-function: steps(10); + } + 100% { + opacity: 1; + } + } } @layer base { @@ -232,8 +247,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } /* Stage-channel sidebar art; ::after ramps to the sidebar bg color and the - mask lets the surface grain show through at the boundary. */ + mask lets the surface grain show through at the boundary. Panels whose + background differs from the app chrome (e.g. sidebar v2) override + --sidebar-stage-fade so the art fades into their own surface color. */ .sidebar-stage-backdrop { + --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background)); mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); } @@ -246,12 +264,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil to bottom, transparent 0%, transparent 28%, - color-mix(in srgb, var(--app-chrome-background) 10%, transparent) 40%, - color-mix(in srgb, var(--app-chrome-background) 30%, transparent) 52%, - color-mix(in srgb, var(--app-chrome-background) 58%, transparent) 64%, - color-mix(in srgb, var(--app-chrome-background) 82%, transparent) 75%, - color-mix(in srgb, var(--app-chrome-background) 96%, transparent) 85%, - var(--app-chrome-background) 93% + color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%, + color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%, + color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%, + color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%, + color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%, + var(--stage-fade) 93% ); } @@ -450,6 +468,41 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +/* Give the navigation rail its own cool graphite surface instead of borrowing + the workspace card color. The scoped semantic tokens keep sidebar v2's + controls in the same palette without changing v1 or the settings nav. */ +.app-sidebar { + --background: oklch(0.965 0.009 265); + --foreground: oklch(0.27 0.025 265); + --card: oklch(0.945 0.014 265); + --card-foreground: var(--foreground); + --accent: oklch(0.875 0.008 265); + --accent-foreground: oklch(0.24 0.012 265); + --muted: oklch(0.9 0.018 265); + --muted-foreground: oklch(0.49 0.03 265); + --border: oklch(0.72 0.025 265 / 28%); + --input: oklch(0.62 0.03 265 / 28%); + --sidebar-stage-fade: var(--card); + background-color: var(--card); +} + +.dark .app-sidebar { + --background: #000; + --foreground: #f1f3f7; + --card: #000; + --card-foreground: var(--foreground); + --accent: #191a1d; + --accent-foreground: #f7f9ff; + --muted: #0a0a0a; + --muted-foreground: #a3a3a3; + --border: rgb(255 255 255 / 14%); + --input: rgb(255 255 255 / 18%); + /* The stage-channel header art must ramp to THIS panel's surface, not the + global chrome background, or the fade shows a seam (same rule as the + light palette above). */ + --sidebar-stage-fade: var(--card); +} + body { font-family: "DM Sans Variable", diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 2b1d7b09b9f..2189f1e3ca2 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -6,6 +6,7 @@ import { resolveNewDraftStartFromOrigin, startNewLocalThreadFromContext, startNewThreadFromContext, + startNewThreadInProjectFromContext, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -117,6 +118,26 @@ describe("chatThreadActions", () => { }); }); + it("does not carry branch or worktree context into a different project", async () => { + const handleNewThread = vi.fn(async () => {}); + const targetProjectRef = scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID); + + await startNewThreadInProjectFromContext( + createContext({ + activeThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + }, + handleNewThread, + }), + targetProjectRef, + ); + + expect(handleNewThread).toHaveBeenCalledWith(targetProjectRef); + }); + it("delegates the target environment defaults to the new-thread handler", async () => { const handleNewThread = vi.fn(async () => {}); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4f30885610a..d8b831ac3ad 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -75,6 +75,14 @@ export async function startNewThreadInProjectFromContext( context: ChatThreadActionContext, projectRef: ScopedProjectRef, ): Promise { + const contextualProjectRef = resolveThreadActionProjectRef(context); + const matchesContext = + contextualProjectRef?.environmentId === projectRef.environmentId && + contextualProjectRef.projectId === projectRef.projectId; + if (!matchesContext) { + await context.handleNewThread(projectRef); + return; + } await context.handleNewThread(projectRef, buildContextualThreadOptions(context)); } diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index b9981bc2e3e..ca9a5986c66 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/newThreadPickerBus.ts b/apps/web/src/newThreadPickerBus.ts new file mode 100644 index 00000000000..a1cc7560e20 --- /dev/null +++ b/apps/web/src/newThreadPickerBus.ts @@ -0,0 +1,14 @@ +// Tiny event bus connecting the global chat.new shortcut (handled in the +// _chat route layout) to SidebarV2's project picker dialog. The route layer +// can't render the picker itself — it lives with the sidebar — and the +// sidebar can't own the window keydown handler without racing the layout's. +const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker"; + +export function openNewThreadPicker(): void { + window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT)); +} + +export function onOpenNewThreadPicker(listener: () => void): () => void { + window.addEventListener(NEW_THREAD_PICKER_EVENT, listener); + return () => window.removeEventListener(NEW_THREAD_PICKER_EVENT, listener); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index b524fe018e9..563d1b43755 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' +import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' @@ -79,6 +80,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ path: '/connections', getParentRoute: () => SettingsRoute, } as any) +const SettingsBetaRoute = SettingsBetaRouteImport.update({ + id: '/beta', + path: '/beta', + getParentRoute: () => SettingsRoute, +} as any) const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', @@ -108,6 +114,7 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -123,6 +130,7 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -141,6 +149,7 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteWithChildren '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -160,6 +169,7 @@ export interface FileRouteTypes { | '/settings' | '/connect/callback' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -175,6 +185,7 @@ export interface FileRouteTypes { | '/settings' | '/connect/callback' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -192,6 +203,7 @@ export interface FileRouteTypes { | '/settings' | '/connect_/callback' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -290,6 +302,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } + '/settings/beta': { + id: '/settings/beta' + path: '/beta' + fullPath: '/settings/beta' + preLoaderRoute: typeof SettingsBetaRouteImport + parentRoute: typeof SettingsRoute + } '/settings/archived': { id: '/settings/archived' path: '/archived' @@ -337,6 +356,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute + SettingsBetaRoute: typeof SettingsBetaRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -347,6 +367,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, + SettingsBetaRoute: SettingsBetaRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 9fb1eae721e..b590c59ed9d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,6 +3,9 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { useClientSettings } from "../hooks/useSettings"; +import { openNewThreadPicker } from "../newThreadPickerBus"; +import { useProjects } from "../state/entities"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -25,6 +28,8 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const projectCount = useProjects().length; const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -75,6 +80,13 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); + // Sidebar v2 routes creation through its project picker whenever + // there is a real choice to make; v1 (and single-project setups) + // keep the immediate contextual create. + if (sidebarV2Enabled && projectCount > 1) { + openNewThreadPicker(); + return; + } void startNewThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined, @@ -140,8 +152,10 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, + projectCount, routeThreadRef, selectedThreadKeysSize, + sidebarV2Enabled, terminalOpen, ]); diff --git a/apps/web/src/routes/settings.beta.tsx b/apps/web/src/routes/settings.beta.tsx new file mode 100644 index 00000000000..a1e78f2dff7 --- /dev/null +++ b/apps/web/src/routes/settings.beta.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel"; + +function SettingsBetaRoute() { + return ; +} + +export const Route = createFileRoute("/settings/beta")({ + component: SettingsBetaRoute, +}); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index db895e37c4a..47e63ca2cd3 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -185,6 +185,16 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); } +/** Whether the environment's server understands thread.settle/unsettle. + False for pre-settlement servers (capability defaults false on decode), + so clients under version skew fall back instead of erroring. */ +export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSettlement === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); } diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 13ff2f0f73e..89734357889 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-02-13T00:00:00.000Z", updatedAt: "2026-02-13T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..4fa05f850e5 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -127,6 +127,10 @@ "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" }, + "./state/thread-settled": { + "types": "./src/state/threadSettled.ts", + "default": "./src/state/threadSettled.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 5cc3f0c1a86..0cb1650066c 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -21,7 +21,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; +import { + archiveThread, + createProject, + settleThread, + stopThreadSession, + unsettleThread, +} from "./commands.ts"; const TEST_CRYPTO_LAYER = Layer.succeed( Crypto.Crypto, @@ -134,4 +140,35 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + + it.effect("dispatches settle and unsettle commands without timestamps", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* settleThread({ + commandId: CommandId.make("settle-command"), + threadId: ThreadId.make("thread-1"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + yield* unsettleThread({ + commandId: CommandId.make("unsettle-command"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.settle", + commandId: "settle-command", + threadId: "thread-1", + }, + { + type: "thread.unsettle", + commandId: "unsettle-command", + threadId: "thread-1", + reason: "user", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index a0c3cbe771f..ef767854804 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -35,6 +35,8 @@ export type CreateThreadInput = CommandInput<"thread.create">; export type DeleteThreadInput = CommandInput<"thread.delete">; export type ArchiveThreadInput = CommandInput<"thread.archive">; export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; +export type SettleThreadInput = CommandInput<"thread.settle">; +export type UnsettleThreadInput = CommandInput<"thread.unsettle">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -153,6 +155,26 @@ export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = E }); }); +export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.settleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.settle", + commandId: yield* commandId(input), + }); +}); + +export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.unsettleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.unsettle", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index c772d134a67..e08fd9e552f 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -96,6 +96,8 @@ const THREAD_SHELL = { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index a069460e63c..fdccc4c47dd 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -36,6 +36,8 @@ const stubThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index aab5110e9cf..ea87298e98f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -12,9 +12,11 @@ import { type RevertThreadCheckpointInput, type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, + type SettleThreadInput, type StartThreadTurnInput, type StopThreadSessionInput, type UnarchiveThreadInput, + type UnsettleThreadInput, type UpdateThreadMetadataInput, archiveThread, createThread, @@ -25,9 +27,11 @@ import { revertThreadCheckpoint, setThreadInteractionMode, setThreadRuntimeMode, + settleThread, startThreadTurn, stopThreadSession, unarchiveThread, + unsettleThread, updateThreadMetadata, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -42,9 +46,11 @@ export type { RevertThreadCheckpointInput, SetThreadInteractionModeInput, SetThreadRuntimeModeInput, + SettleThreadInput, StartThreadTurnInput, StopThreadSessionInput, UnarchiveThreadInput, + UnsettleThreadInput, UpdateThreadMetadataInput, } from "../operations/commands.ts"; @@ -82,6 +88,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + settle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:settle", + execute: (input: SettleThreadInput) => settleThread(input), + scheduler, + concurrency, + }), + unsettle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:unsettle", + execute: (input: UnsettleThreadInput) => unsettleThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 430900bdbb0..770738ad9bb 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -57,6 +57,8 @@ export function mergeEnvironmentThread( createdAt: shell.createdAt, updatedAt: shell.updatedAt, archivedAt: shell.archivedAt, + settledOverride: shell.settledOverride, + settledAt: shell.settledAt, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..211f8748f4e 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -34,6 +34,8 @@ const baseThread: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -164,6 +166,62 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.settled / thread.unsettled", () => { + it("sets the settled override and timestamp", () => { + const settledAt = "2026-04-01T05:00:00.000Z"; + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 5, + occurredAt: settledAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt, + updatedAt: settledAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe("settled"); + expect(result.thread.settledAt).toBe(settledAt); + } + }); + + it.each([ + ["user", "active"], + ["activity", null], + ] as const)("unsettles for %s with override %s", (reason, settledOverride) => { + const settledThread: OrchestrationThread = { + ...baseThread, + settledOverride: "settled", + settledAt: "2026-04-01T05:00:00.000Z", + }; + const updatedAt = "2026-04-01T06:00:00.000Z"; + const result = applyThreadDetailEvent(settledThread, { + ...baseEventFields, + sequence: 6, + occurredAt: updatedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.unsettled", + payload: { + threadId: ThreadId.make("thread-1"), + reason, + updatedAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe(settledOverride); + expect(result.thread.settledAt).toBeNull(); + } + }); + }); + describe("thread.meta-updated", () => { it("patches title and branch", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..7fdee37c0e6 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -72,6 +72,8 @@ export function applyThreadDetailEvent( createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -100,6 +102,28 @@ export function applyThreadDetailEvent( thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, }; + case "thread.settled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.unsettled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }, + }; + // ── Thread metadata ───────────────────────────────────────────── case "thread.meta-updated": return { diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts new file mode 100644 index 00000000000..50f3c2d3912 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -0,0 +1,345 @@ +import { + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + canSettle, + effectiveSettled, + hasQueuedTurnStart, + threadLastActivityAt, + type ChangeRequestStateLike, +} from "./threadSettled.ts"; + +const NOW = "2026-04-10T00:00:00.000Z"; +const FRESH = "2026-04-09T00:00:00.000Z"; +const STALE = "2026-04-06T23:59:59.999Z"; + +function makeShell(input: { + readonly settledOverride?: "settled" | "active" | null; + readonly activityAt: string | null; + readonly sessionStatus?: "starting" | "running"; + readonly pending?: "approval" | "user-input"; +}): OrchestrationThreadShell { + const threadId = ThreadId.make("thread-1"); + return { + id: threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: + input.activityAt === null + ? null + : { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: input.activityAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: NOW, + archivedAt: null, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledOverride === "settled" ? NOW : null, + session: + input.sessionStatus === undefined + ? null + : { + threadId, + status: input.sessionStatus, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + latestUserMessageAt: null, + hasPendingApprovals: input.pending === "approval", + hasPendingUserInput: input.pending === "user-input", + hasActionableProposedPlan: false, + }; +} + +describe("threadLastActivityAt", () => { + it("returns the latest real user or turn activity and ignores thread/session updates", () => { + const shell = makeShell({ activityAt: null, sessionStatus: "running" }); + const withActivity: OrchestrationThreadShell = { + ...shell, + latestUserMessageAt: "2026-04-04T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-03T00:00:00.000Z", + startedAt: "2026-04-05T00:00:00.000Z", + completedAt: "2026-04-06T00:00:00.000Z", + assistantMessageId: null, + }, + }; + + expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); + expect(threadLastActivityAt(shell)).toBeNull(); + }); +}); + +describe("effectiveSettled", () => { + const overrideCases = [null, "settled", "active"] as const; + const changeRequestStates = [undefined, "open", "merged"] as const; + const inactivityCases = [ + ["fresh", FRESH], + ["stale", STALE], + ["no-activity", null], + ] as const; + const runningCases = [false, true] as const; + const pendingCases = [undefined, "approval", "user-input"] as const; + const truthTable = overrideCases.flatMap((settledOverride) => + changeRequestStates.flatMap((changeRequestState) => + inactivityCases.flatMap(([inactivity, activityAt]) => + runningCases.flatMap((running) => + pendingCases.map((pending) => ({ + settledOverride, + changeRequestState, + inactivity, + activityAt, + running, + pending, + // Settled iff nothing blocks (pending work / live session) AND + // the override says settled, or (with no override) a merged PR + // or staleness auto-settles. The "active" pin suppresses both + // auto signals. + expected: + pending === undefined && + !running && + (settledOverride === "settled" || + (settledOverride === null && + (changeRequestState === "merged" || inactivity === "stale"))), + })), + ), + ), + ), + ); + + it.each(truthTable)( + "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", + ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { + const shell = makeShell({ + settledOverride, + activityAt, + ...(running ? { sessionStatus: "running" as const } : {}), + ...(pending === undefined ? {} : { pending }), + }); + const changeRequestOptions = + changeRequestState === undefined + ? {} + : { changeRequestState: changeRequestState as ChangeRequestStateLike }; + + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: 3, + ...changeRequestOptions, + }), + ).toBe(expected); + }, + ); + + it("treats closed change requests like merged ones", () => { + const shell = makeShell({ activityAt: null }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + }); + + it("never settles a starting session, even with a settled override", () => { + const shell = makeShell({ + settledOverride: "settled", + activityAt: STALE, + sessionStatus: "starting", + }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: 3, + changeRequestState: "merged", + }), + ).toBe(false); + }); + + it("uses a strict inactivity boundary and honors a null threshold", () => { + const boundary = makeShell({ + activityAt: "2026-04-07T00:00:00.000Z", + }); + const stale = makeShell({ activityAt: STALE }); + + expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); + expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); + }); +}); + +describe("hasQueuedTurnStart", () => { + const QUEUED_AT = "2026-04-09T12:00:00.000Z"; + // Within the adoption grace window of the queued message. + const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" }; + + it("flags a user message no turn has picked up, within the grace window", () => { + const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; + expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true); + + const staleTurn = { + ...makeShell({ activityAt: FRESH }), + latestUserMessageAt: QUEUED_AT, + }; + expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true); + }); + + it("expires after the grace window: an unadopted message is a failed start, not queued work", () => { + const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; + expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false); + // Historical shells (e.g. from servers that never carried latestTurn) + // must never read as queued. + expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false); + }); + + it("clears once a turn adopts the message or the start fails", () => { + const adopted = { + ...makeShell({ activityAt: QUEUED_AT }), + latestUserMessageAt: QUEUED_AT, + }; + expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false); + + const failed = makeShell({ activityAt: FRESH }); + const failedShell = { + ...failed, + latestUserMessageAt: QUEUED_AT, + session: { + threadId: failed.id, + status: "error" as const, + providerName: "Codex", + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: "boom", + updatedAt: NOW, + }, + }; + expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false); + }); + + it("is quiet without user messages", () => { + expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false); + }); + + it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => { + // Message timestamps originate on other devices; a clock an hour ahead + // must not hold the queued state for the whole skew. + const skewed = { + latestUserMessageAt: "2026-04-09T13:00:00.000Z", + latestTurn: null, + session: null, + }; + expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false); + // A small negative age (within the grace window) still reads as queued. + const slightlyAhead = { + latestUserMessageAt: "2026-04-09T12:00:30.000Z", + latestTurn: null, + session: null, + }; + expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true); + }); +}); + +describe("canSettle", () => { + it("blocks every state effectiveSettled refuses to classify as settled", () => { + expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true); + expect( + canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }), + ).toBe(false); + expect( + canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }), + ).toBe(false); + expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe( + false, + ); + expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe( + false, + ); + }); + + it("blocks settling a queued turn start, only within the grace window", () => { + const queued = { + ...makeShell({ activityAt: FRESH }), + latestUserMessageAt: "2026-04-09T12:00:00.000Z", + }; + const justAfter = "2026-04-09T12:00:30.000Z"; + expect(canSettle(queued, { now: justAfter })).toBe(false); + // effectiveSettled must agree: queued work never auto-settles either, + // even with a merged PR. + expect( + effectiveSettled(queued, { + now: justAfter, + autoSettleAfterDays: 3, + changeRequestState: "merged", + }), + ).toBe(false); + // Past the window the message is a failed/stale start: settleable again. + expect(canSettle(queued, { now: NOW })).toBe(true); + }); + + it("lets a server-accepted settle overrule the clock-derived queued blocker", () => { + // The settle action ran with wall-clock `now` (past the grace window); + // the list partition re-evaluates with a minute-floored `now` that is + // still INSIDE the window. settledAt >= message time proves the server + // already adjudicated this exact message, so the row must not snap back + // to active until the coarser clock catches up. + const messageAt = "2026-04-09T12:00:00.000Z"; + const flooredNow = "2026-04-09T12:01:00.000Z"; + const base = makeShell({ settledOverride: "settled", activityAt: null }); + const settledAfterMessage = { + ...base, + latestUserMessageAt: messageAt, + settledAt: "2026-04-09T12:02:10.000Z", + }; + expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true); + expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe( + true, + ); + + // A message NEWER than settledAt is genuinely new work: still blocked + // until the server's auto-unsettle lands. + const messageAfterSettle = { + ...base, + latestUserMessageAt: "2026-04-09T12:03:00.000Z", + settledAt: "2026-04-09T12:02:10.000Z", + }; + expect( + effectiveSettled(messageAfterSettle, { + now: "2026-04-09T12:03:30.000Z", + autoSettleAfterDays: 3, + }), + ).toBe(false); + }); + + it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { + // Anything canSettle rejects must render as active even when the user + // settled it earlier. + const blocked = makeShell({ + settledOverride: "settled", + activityAt: FRESH, + pending: "user-input", + }); + expect(canSettle(blocked, { now: NOW })).toBe(false); + expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts new file mode 100644 index 00000000000..3cb4f7e65f7 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -0,0 +1,147 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export type ChangeRequestStateLike = "open" | "closed" | "merged"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { + const candidates = [ + shell.latestUserMessageAt, + shell.latestTurn?.requestedAt, + shell.latestTurn?.startedAt, + shell.latestTurn?.completedAt, + ]; + let latest: string | null = null; + let latestTimestamp = Number.NEGATIVE_INFINITY; + + for (const candidate of candidates) { + if (candidate === null || candidate === undefined) continue; + const timestamp = Date.parse(candidate); + if (timestamp > latestTimestamp) { + latest = candidate; + latestTimestamp = timestamp; + } + } + + return latest; +} + +/** + * A queued turn start lives for at most this long: session adoption takes + * seconds, so a user message still unadopted after the grace window is a + * failed start (or stale data — shells from older servers can carry user + * messages with no latestTurn at all), not pending work. Without this bound + * such threads would be permanently unsettleable. + */ +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +/** + * A user message no turn has picked up yet: the turn.start command was + * dispatched (message-sent + turn-start-requested) but no session has + * adopted it, so `session` is still null and the pending work is invisible + * to the session-status checks. Detectable as a user message strictly newer + * than every timestamp on the latest turn — on adoption the new turn's + * requestedAt equals the message time, clearing the condition — and only + * within the adoption grace window. + */ +export function hasQueuedTurnStart( + shell: Pick, + options: { readonly now: string }, +): boolean { + if (shell.latestUserMessageAt == null) return false; + // A failed session start clears the queued state: the failure is already + // visible (status edge / error). + if (shell.session?.status === "error") return false; + const messageAt = Date.parse(shell.latestUserMessageAt); + if (Number.isNaN(messageAt)) return false; + const nowMs = Date.parse(options.now); + if (Number.isNaN(nowMs)) return false; + // Bounded on both sides: message timestamps originate on whichever device + // sent the message, so a clock ahead of this one yields a negative age + // that would otherwise hold the queued state for the whole skew. Mirrors + // the decider's guard. + if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; + const turn = shell.latestTurn; + if (turn === null) return true; + return [turn.requestedAt, turn.startedAt, turn.completedAt].every( + (candidate) => candidate == null || Date.parse(candidate) < messageAt, + ); +} + +/** + * A thread may be settled only when none of effectiveSettled's activity + * blockers hold. This is deliberately the same list: anything the partition + * refuses to CLASSIFY as settled must also be refused as a settle TARGET. + * The server enforces its own invariants; this client-side twin exists so + * the UI can disable/reject before a round trip. + */ +export function canSettle( + shell: Pick< + OrchestrationThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" + >, + options: { readonly now: string }, +): boolean { + if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; + if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + // Queued work is as blocked-on-progress as a live session: settling it + // (or auto-settling it on a closed PR) would hide a just-requested turn. + if (hasQueuedTurnStart(shell, options)) return false; + return true; +} + +/** + * Settled resolution over the server-backed settled lifecycle. The explicit + * user override (thread.settle / thread.unsettle commands, projected into + * settledOverride + settledAt) wins in both directions; without one, a + * thread auto-settles on a merged/closed PR or inactivity past the window. + * The server un-settles on real activity (user message, session start, + * approval/user-input request), so an override never goes stale silently. + */ +export function effectiveSettled( + shell: OrchestrationThreadShell, + options: { + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly changeRequestState?: ChangeRequestStateLike | null; + }, +): boolean { + // Blocked work must remain visible even when a user explicitly settled it. + if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; + if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + if (hasQueuedTurnStart(shell, { now: options.now })) { + // The queued-turn blocker alone is forgivable: it is clock-derived, and + // list callers pass a coarser `now` than the settle action used. When + // the server already adjudicated the queued message by accepting a + // settle after it (settledAt stamps server accept time), trust that + // ruling — otherwise a settle near the grace boundary leaves the row + // pinned active until the caller's clock ticks over. A message NEWER + // than settledAt is genuinely new work and keeps the block until the + // server's auto-unsettle lands. + const serverAdjudicated = + shell.settledOverride === "settled" && + shell.settledAt !== null && + shell.latestUserMessageAt !== null && + Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt); + if (!serverAdjudicated) return false; + } + if (shell.settledOverride === "settled") return true; + // "active" is the explicit keep-active pin: it suppresses auto-settle + // until real activity clears it server-side. + if (shell.settledOverride === "active") return false; + if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { + return true; + } + if (options.autoSettleAfterDays === null) return false; + + const lastActivityAt = threadLastActivityAt(shell); + if (lastActivityAt === null) return false; + + // threadLastActivityAt only returns candidates whose Date.parse beat + // -Infinity, so this parse is a real number; a malformed `now` yields NaN, + // the comparison is false, and the thread stays active (never a surprise + // auto-settle on bad input). + return ( + Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS + ); +} diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index f3c4c4e6338..8d0e4d0a35c 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -69,6 +69,8 @@ const BASE_THREAD: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index bc8db25a95a..6fc0c914d8a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -23,6 +23,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.settle / thread.unsettle commands. Absent on + pre-settlement servers, so clients treat missing as unsupported and + never send the commands under version skew. */ + threadSettlement: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 29a732ca69b..1ebc23a483b 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -15,6 +15,8 @@ import { ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, + OrchestrationThread, + OrchestrationThreadShell, ProjectCreateCommand, ThreadMetaUpdatedPayload, ThreadTurnStartCommand, @@ -37,6 +39,8 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); +const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeOrchestrationThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload); function getOptionValue( @@ -344,6 +348,75 @@ it.effect("decodes thread archive and unarchive commands", () => }), ); +it.effect("decodes thread settle and unsettle commands", () => + Effect.gen(function* () { + const settle = yield* decodeOrchestrationCommand({ + type: "thread.settle", + commandId: "cmd-settle-1", + threadId: "thread-1", + }); + const unsettle = yield* decodeOrchestrationCommand({ + type: "thread.unsettle", + commandId: "cmd-unsettle-1", + threadId: "thread-1", + reason: "user", + }); + + assert.strictEqual(settle.type, "thread.settle"); + assert.strictEqual(unsettle.type, "thread.unsettle"); + + // "activity" is server-owned: it exists on the event, never on the + // command, so a client cannot forge the neutral reset. + const forged = yield* decodeOrchestrationCommand({ + type: "thread.unsettle", + commandId: "cmd-unsettle-2", + threadId: "thread-1", + reason: "activity", + }).pipe(Effect.flip); + assert.ok(forged); + }), +); + +it.effect("defaults settled fields when decoding historical thread data", () => + Effect.gen(function* () { + const common = { + id: "thread-1", + projectId: "project-1", + title: "Historical thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + session: null, + }; + const thread = yield* decodeOrchestrationThread({ + ...common, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + }); + const shell = yield* decodeOrchestrationThreadShell({ + ...common, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }); + + assert.strictEqual(thread.settledOverride, null); + assert.strictEqual(thread.settledAt, null); + assert.strictEqual(shell.settledOverride, null); + assert.strictEqual(shell.settledAt, null); + }), +); + it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ @@ -388,6 +461,48 @@ it.effect("decodes thread archived and unarchived events", () => }), ); +it.effect("decodes thread settled and unsettled events", () => + Effect.gen(function* () { + const settled = yield* decodeOrchestrationEvent({ + sequence: 1, + eventId: "event-settle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.settled", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-settle-1", + causationEventId: null, + correlationId: "cmd-settle-1", + metadata: {}, + payload: { + threadId: "thread-1", + settledAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const unsettled = yield* decodeOrchestrationEvent({ + sequence: 2, + eventId: "event-unsettle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.unsettled", + occurredAt: "2026-01-02T00:00:00.000Z", + commandId: "cmd-unsettle-1", + causationEventId: null, + correlationId: "cmd-unsettle-1", + metadata: {}, + payload: { + threadId: "thread-1", + reason: "user", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + }); + + assert.strictEqual(settled.type, "thread.settled"); + assert.strictEqual(unsettled.type, "thread.unsettled"); + }), +); + it.effect("accepts provider-scoped model options in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c0c9c62d080..b01df310062 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -356,6 +356,10 @@ export const OrchestrationThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -402,6 +406,10 @@ export const OrchestrationThreadShell = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -558,6 +566,22 @@ const ThreadUnarchiveCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadSettleCommand = Schema.Struct({ + type: Schema.Literal("thread.settle"), + commandId: CommandId, + threadId: ThreadId, +}); + +const ThreadUnsettleCommand = Schema.Struct({ + type: Schema.Literal("thread.unsettle"), + commandId: CommandId, + threadId: ThreadId, + // Commands only carry "user": activity un-settles are decided server-side + // (the decider emits thread.unsettled(reason: "activity") events directly, + // never through this command), so a client cannot forge the neutral reset. + reason: Schema.Literal("user"), +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -700,6 +724,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -721,6 +747,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -823,6 +851,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.deleted", "thread.archived", "thread.unarchived", + "thread.settled", + "thread.unsettled", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -902,6 +932,18 @@ export const ThreadUnarchivedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadSettledPayload = Schema.Struct({ + threadId: ThreadId, + settledAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadUnsettledPayload = Schema.Struct({ + threadId: ThreadId, + reason: Schema.Literals(["user", "activity"]), + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1069,6 +1111,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unarchived"), payload: ThreadUnarchivedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.settled"), + payload: ThreadSettledPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.unsettled"), + payload: ThreadUnsettledPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index a79042a2847..001daf1b239 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -4,12 +4,14 @@ import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, + ClientSettingsPatch, DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch, } from "./settings.ts"; const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); +const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -31,6 +33,25 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings sidebar v2", () => { + it("defaults the beta off with a three-day auto-settle threshold", () => { + const settings = decodeClientSettings({}); + expect(settings.sidebarV2Enabled).toBe(false); + expect(settings.sidebarAutoSettleAfterDays).toBe(3); + }); + + it("allows auto-settle by inactivity to be disabled", () => { + expect( + decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, + ).toBeNull(); + }); + + it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { + expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b983aa8a3fa..2f3c1aafe69 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -38,6 +38,16 @@ export const SidebarThreadPreviewCount = Schema.Int.check( ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; +export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; +export const SidebarAutoSettleAfterDays = Schema.Number.check( + Schema.isBetween({ + minimum: MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + maximum: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + }), +); +export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; +export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -72,6 +82,9 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), + ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -88,6 +101,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -567,6 +581,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), @@ -574,6 +589,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), }); From 282ecb317877e6cce296976a03278cc388b7ca37 Mon Sep 17 00:00:00 2001 From: Leonel Rivas Date: Wed, 22 Jul 2026 06:54:20 -0700 Subject: [PATCH 036/110] fix(settings): validate the add-provider wizard step before advancing (#2813) (#3100) Co-authored-by: Julius Marminge Co-authored-by: codex --- .../AddProviderInstanceDialog.logic.ts | 36 ++++++ .../AddProviderInstanceDialog.test.ts | 44 +++++++ .../settings/AddProviderInstanceDialog.tsx | 115 +++++++----------- .../AddProviderInstanceWizardSteps.test.tsx | 61 ++++++++++ .../AddProviderInstanceWizardSteps.tsx | 70 +++++++++++ 5 files changed, 254 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts create mode 100644 apps/web/src/components/settings/AddProviderInstanceDialog.test.ts create mode 100644 apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx create mode 100644 apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts new file mode 100644 index 00000000000..fdffa9a190e --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -0,0 +1,36 @@ +export type WizardNavigation = + | { readonly kind: "navigate"; readonly step: number } + | { readonly kind: "blocked"; readonly step: number; readonly error: string }; + +const IDENTITY_STEP = 1; + +export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const; + +/** + * Resolve navigation within the add-provider wizard. + * + * Moving forward past Identity requires a valid instance id, whether the user + * advances one step at a time or skips directly to Config from a step header. + * A blocked skip lands on Identity so its existing inline validation is + * visible. Backward navigation is always preserved. + */ +export function resolveWizardNavigation( + currentStep: number, + requestedStep: number, + stepCount: number, + validation: { readonly instanceIdError: string | null }, +): WizardNavigation { + const lastStep = Math.max(0, stepCount - 1); + const targetStep = Math.max(0, Math.min(lastStep, requestedStep)); + const movesForwardPastIdentity = currentStep <= IDENTITY_STEP && targetStep > IDENTITY_STEP; + + if (movesForwardPastIdentity && validation.instanceIdError !== null) { + return { + kind: "blocked", + step: Math.min(IDENTITY_STEP, lastStep), + error: validation.instanceIdError, + }; + } + + return { kind: "navigate", step: targetStep }; +} diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts new file mode 100644 index 00000000000..594d2e4537e --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic"; + +describe("resolveWizardNavigation", () => { + const invalidId = { instanceIdError: "Instance ID is required." }; + const validId = { instanceIdError: null }; + + it("allows moving from Driver to Identity before the instance id is valid", () => { + expect(resolveWizardNavigation(0, 1, 3, invalidId)).toEqual({ kind: "navigate", step: 1 }); + }); + + it("blocks Next from Identity to Config while the instance id is invalid", () => { + expect(resolveWizardNavigation(1, 2, 3, invalidId)).toEqual({ + kind: "blocked", + step: 1, + error: "Instance ID is required.", + }); + }); + + it("stops a direct Driver-to-Config skip at Identity and surfaces its error", () => { + expect(resolveWizardNavigation(0, 2, 3, invalidId)).toEqual({ + kind: "blocked", + step: 1, + error: "Instance ID is required.", + }); + }); + + it("allows advancing and skipping forward once the instance id is valid", () => { + expect(resolveWizardNavigation(1, 2, 3, validId)).toEqual({ kind: "navigate", step: 2 }); + expect(resolveWizardNavigation(0, 2, 3, validId)).toEqual({ kind: "navigate", step: 2 }); + }); + + it("always preserves backward Driver and Identity navigation", () => { + expect(resolveWizardNavigation(2, 1, 3, invalidId)).toEqual({ kind: "navigate", step: 1 }); + expect(resolveWizardNavigation(2, 0, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); + expect(resolveWizardNavigation(1, 0, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); + }); + + it("clamps requested steps to the wizard bounds", () => { + expect(resolveWizardNavigation(2, 8, 3, validId)).toEqual({ kind: "navigate", step: 2 }); + expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 77c1813f110..ed808e30b0c 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -1,8 +1,7 @@ "use client"; -import { CheckIcon } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { ProviderInstanceId, ProviderDriverKind, @@ -29,6 +28,12 @@ import { toastManager } from "../ui/toast"; import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; import { AnimatedHeight } from "../AnimatedHeight"; +import { + ADD_PROVIDER_WIZARD_STEPS, + resolveWizardNavigation, + type WizardNavigation, +} from "./AddProviderInstanceDialog.logic"; +import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; const PROVIDER_ACCENT_SWATCHES = [ "#2563eb", @@ -143,26 +148,37 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns const instanceIdError = validateInstanceId(instanceId, existingIds); const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; const previewLabel = label.trim() || `${driverOption.label} Workspace`; - const wizardSteps = ["Driver", "Identity", "Config"] as const; const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; - const setConfigDraft = useCallback( - (config: Record | undefined) => { - setConfigByDriver((existing) => { - const next = { ...existing }; - if (config === undefined || Object.keys(config).length === 0) { - delete next[driver]; - } else { - next[driver] = config; - } - return next; - }); - }, - [driver], - ); + const setConfigDraft = (config: Record | undefined) => { + setConfigByDriver((existing) => { + const next = { ...existing }; + if (config === undefined || Object.keys(config).length === 0) { + delete next[driver]; + } else { + next[driver] = config; + } + return next; + }); + }; + + const applyWizardNavigation = (navigation: WizardNavigation) => { + if (navigation.kind === "blocked") { + setHasAttemptedSubmit(true); + } + setWizardStep(navigation.step); + }; - const handleSave = useCallback(() => { + const navigateToStep = (requestedStep: number) => { + applyWizardNavigation( + resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { + instanceIdError, + }), + ); + }; + + const handleSave = () => { setHasAttemptedSubmit(true); if (instanceIdError !== null) return; @@ -201,18 +217,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns description: error instanceof Error ? error.message : "Update failed.", }); } - }, [ - driver, - driverOption, - configByDriver, - instanceId, - instanceIdError, - label, - accentColor, - onOpenChange, - settings.providerInstances, - updateSettings, - ]); + }; return ( @@ -224,46 +229,12 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns Configure an additional provider instance — for example, a second Codex install pointed at a different workspace. -
    - {wizardSteps.map((step, index) => ( - - ))} -
    +
    {wizardStep === 0 ? "Cancel" : "Back"} - {wizardStep < wizardSteps.length - 1 ? ( - ) : ( diff --git a/apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx b/apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx new file mode 100644 index 00000000000..964779f4ec9 --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceWizardSteps.test.tsx @@ -0,0 +1,61 @@ +import { Children, isValidElement, type ReactElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { ADD_PROVIDER_WIZARD_STEPS } from "./AddProviderInstanceDialog.logic"; +import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; + +interface StepButtonProps { + readonly "aria-current"?: string; + readonly onClick: () => void; +} + +function renderStepButtons( + currentStep: number, + instanceIdError: string | null, + onNavigation: Parameters[0]["onNavigation"], +): ReactElement[] { + const header = AddProviderInstanceWizardSteps({ + currentStep, + summaries: ["Codex", "Codex Workspace", null], + instanceIdError, + onNavigation, + }); + + return Children.toArray(header.props.children).filter( + (child): child is ReactElement => isValidElement(child), + ); +} + +describe("AddProviderInstanceWizardSteps", () => { + it("gates the actual Config header click through Identity validation", () => { + const onNavigation = vi.fn(); + const buttons = renderStepButtons(0, "Instance ID is required.", onNavigation); + + expect(buttons).toHaveLength(ADD_PROVIDER_WIZARD_STEPS.length); + buttons[2]!.props.onClick(); + + expect(onNavigation).toHaveBeenCalledOnce(); + expect(onNavigation).toHaveBeenCalledWith({ + kind: "blocked", + step: 1, + error: "Instance ID is required.", + }); + }); + + it("marks the wizard step separately from the clicked button focus", () => { + const buttons = renderStepButtons(1, "Instance ID is required.", vi.fn()); + + expect(buttons[0]!.props["aria-current"]).toBeUndefined(); + expect(buttons[1]!.props["aria-current"]).toBe("step"); + expect(buttons[2]!.props["aria-current"]).toBeUndefined(); + }); + + it("preserves the actual backward header click", () => { + const onNavigation = vi.fn(); + const buttons = renderStepButtons(2, "Instance ID is required.", onNavigation); + + buttons[0]!.props.onClick(); + + expect(onNavigation).toHaveBeenCalledWith({ kind: "navigate", step: 0 }); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx b/apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx new file mode 100644 index 00000000000..4747557471d --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx @@ -0,0 +1,70 @@ +import { CheckIcon } from "lucide-react"; + +import { cn } from "../../lib/utils"; +import { + ADD_PROVIDER_WIZARD_STEPS, + resolveWizardNavigation, + type WizardNavigation, +} from "./AddProviderInstanceDialog.logic"; + +interface AddProviderInstanceWizardStepsProps { + readonly currentStep: number; + readonly summaries: readonly (string | null)[]; + readonly instanceIdError: string | null; + readonly onNavigation: (navigation: WizardNavigation) => void; +} + +export function AddProviderInstanceWizardSteps({ + currentStep, + summaries, + instanceIdError, + onNavigation, +}: AddProviderInstanceWizardStepsProps) { + return ( +
    + {ADD_PROVIDER_WIZARD_STEPS.map((step, index) => ( + + ))} +
    + ); +} From aa5ec8036451a8dcf16d93294c69f524907c42c8 Mon Sep 17 00:00:00 2001 From: Jaret Bottoms Date: Wed, 22 Jul 2026 08:54:30 -0500 Subject: [PATCH 037/110] fix(claude): isolate capability probe from user MCP servers (#4015) Co-authored-by: codex --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 138 ++++++++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 54 +++++-- 2 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts new file mode 100644 index 00000000000..ab6e5992990 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -0,0 +1,138 @@ +import { ClaudeSettings } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + buildClaudeCapabilitiesProbeQueryOptions, + CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + probeClaudeCapabilities, +} from "./ClaudeProvider.ts"; + +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + +it("isolates Claude capability probes without dropping workspace setting sources", () => { + const abortController = new AbortController(); + const options = buildClaudeCapabilitiesProbeQueryOptions({ + executablePath: "/usr/bin/claude", + abortController, + environment: { + HOME: "/home/user", + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + cwd: "/workspace/project", + }); + + assert.deepEqual(options.mcpServers, {}); + assert.equal(options.strictMcpConfig, true); + assert.equal(options.cwd, "/workspace/project"); + assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); + assert.deepEqual(options.allowedTools, []); + assert.equal(options.persistSession, false); + assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); + assert.equal(options.abortController, abortController); + assert.equal(options.env?.HOME, "/home/user"); + assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); +}); + +it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { + it.effect("serializes strict no-MCP options and still resolves account capabilities", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); + const executablePath = path.join(tempDir, "fake-claude.mjs"); + const invocationPath = path.join(tempDir, "invocation.json"); + const workspaceCwd = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspaceCwd, { recursive: true }); + + yield* fs.writeFileString( + executablePath, + [ + "#!/usr/bin/env node", + 'import { existsSync, readFileSync, writeFileSync } from "node:fs";', + 'import { createInterface } from "node:readline";', + "const args = process.argv.slice(2);", + 'const mcpConfigIndex = args.indexOf("--mcp-config");', + "const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined;", + "let mcpConfig;", + "if (rawMcpConfig) {", + ' const contents = existsSync(rawMcpConfig) ? readFileSync(rawMcpConfig, "utf8") : rawMcpConfig;', + " try { mcpConfig = JSON.parse(contents); } catch { mcpConfig = contents; }", + "}", + "writeFileSync(process.env.T3_PROBE_INVOCATION_PATH, JSON.stringify({", + " args,", + " cwd: process.cwd(),", + " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,", + " mcpConfig,", + "}));", + "const lines = createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', + " agents: [],", + ' output_style: "default",', + ' available_output_styles: ["default"],', + " models: [],", + ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fs.chmod(executablePath, 0o755); + + const capabilities = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: executablePath }), + { + ...process.env, + T3_PROBE_INVOCATION_PATH: invocationPath, + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + workspaceCwd, + ); + + assert.deepEqual(capabilities, { + email: "dev@example.com", + subscriptionType: "pro", + tokenSource: "oauth", + apiProvider: undefined, + slashCommands: [ + { + name: "review", + description: "Review changes", + input: { hint: "[path]" }, + }, + ], + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { + readonly args: ReadonlyArray; + readonly cwd: string; + readonly connectorEnv: string; + readonly mcpConfig: unknown; + }; + assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); + assert.equal(invocation.connectorEnv, "false"); + assert.equal(invocation.args.includes("--strict-mcp-config"), true); + assert.equal(invocation.args.includes("--mcp-config"), false); + assert.equal(invocation.mcpConfig, undefined); + + assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9df672f54b0..4920f96465a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -21,8 +21,10 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, + type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, type SDKUserMessage, + type SettingSource, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -508,6 +510,44 @@ function apiProviderAuthMetadata( // `undefined` and left the provider unverified and unselectable in the picker. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +/** + * Keep workspace-scoped command discovery intact while isolating the periodic + * health check from configured MCP servers. + */ +export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ + "user", + "project", + "local", +] as const satisfies ReadonlyArray; + +/** Build the exact SDK options used by the periodic Claude capability probe. */ +export function buildClaudeCapabilitiesProbeQueryOptions(input: { + readonly executablePath: string; + readonly abortController: AbortController; + readonly environment: NodeJS.ProcessEnv; + readonly cwd: string | undefined; +}): ClaudeQueryOptions { + return { + persistSession: false, + pathToClaudeCodeExecutable: input.executablePath, + abortController: input.abortController, + settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], + allowedTools: [], + // Ignore MCP definitions from every filesystem setting source above. The + // SDK combines this empty explicit map with --strict-mcp-config. + mcpServers: {}, + strictMcpConfig: true, + env: { + ...input.environment, + // Connected claude.ai MCP servers are discovered outside filesystem + // config; disable them independently for this health check. + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + ...(input.cwd ? { cwd: input.cwd } : {}), + stderr: () => {}, + }; +} + function nonEmptyProbeString(value: string): string | undefined { const candidate = value.trim(); return candidate ? candidate : undefined; @@ -631,16 +671,12 @@ const probeClaudeCapabilities = ( prompt: (async function* (): AsyncGenerator { await waitForAbortSignal(abort.signal); })(), - options: { - persistSession: false, - pathToClaudeCodeExecutable: executablePath, + options: buildClaudeCapabilitiesProbeQueryOptions({ + executablePath, abortController: abort, - settingSources: ["user", "project", "local"], - allowedTools: [], - env: claudeEnvironment, - ...(cwd ? { cwd } : {}), - stderr: () => {}, - }, + environment: claudeEnvironment, + cwd, + }), }); const init = await q.initializationResult(); const account = init.account as From 783692afc10dd63693b3f65ca334322762293e67 Mon Sep 17 00:00:00 2001 From: Ishan Date: Wed, 22 Jul 2026 19:24:55 +0530 Subject: [PATCH 038/110] Preserve connecting status while a turn starts (#4101) Co-authored-by: codex --- .../Layers/ProjectionPipeline.test.ts | 68 +++++++ .../Layers/ProjectionPipeline.ts | 9 + .../Layers/ProviderCommandReactor.test.ts | 125 +++++++++++- .../Layers/ProviderCommandReactor.ts | 44 ++++- .../Layers/ProviderRuntimeIngestion.test.ts | 180 ++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 26 ++- .../src/state/threadSettled.test.ts | 45 +++++ 7 files changed, 475 insertions(+), 22 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index a9d7317999a..926182a3ef0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2464,6 +2464,74 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { ); }); +it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test-"))( + "OrchestrationProjectionPipeline pending turn cleanup", + (it) => { + it.effect("clears pending turn starts when startup reaches a terminal session state", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + + for (const [index, status] of (["error", "interrupted", "stopped"] as const).entries()) { + const threadId = ThreadId.make(`thread-terminal-${status}`); + const requestedAt = `2026-02-26T14:00:0${index}.000Z`; + yield* eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.make(`evt-terminal-pending-${status}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: requestedAt, + commandId: CommandId.make(`cmd-terminal-pending-${status}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-terminal-pending-${status}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-terminal-${status}`), + runtimeMode: "approval-required", + createdAt: requestedAt, + }, + }); + yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make(`evt-terminal-session-${status}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: requestedAt, + commandId: CommandId.make(`cmd-terminal-session-${status}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-terminal-session-${status}`), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status, + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: status === "error" ? "startup failed" : null, + updatedAt: requestedAt, + }, + }, + }); + } + + yield* projectionPipeline.bootstrap; + + const pendingRows = yield* sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM projection_turns + WHERE turn_id IS NULL + AND state = 'pending' + `; + assert.deepEqual(pendingRows, []); + }), + ); + }, +); + it.effect("restores pending turn-start metadata across projection pipeline restart", () => Effect.gen(function* () { const { dbPath } = yield* ServerConfig; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 3ceae0ea43b..cfb88a06cd2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1059,6 +1059,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.session-set": { const turnId = event.payload.session.activeTurnId; if (turnId === null || event.payload.session.status !== "running") { + if ( + event.payload.session.status === "error" || + event.payload.session.status === "stopped" || + event.payload.session.status === "interrupted" + ) { + yield* projectionTurnRepository.deletePendingTurnStartByThreadId({ + threadId: event.payload.threadId, + }); + } // Leaving the "running" session status is the turn-end signal: // settle still-running turns so their duration reflects the whole // turn rather than the last assistant message. diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ce464565dc5..c49646b7a4b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -22,6 +22,7 @@ import { TurnId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -145,6 +146,9 @@ describe("ProviderCommandReactor", () => { readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; + readonly startSessionEffect?: ( + session: ProviderSession, + ) => Effect.Effect; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -159,6 +163,7 @@ describe("ProviderCommandReactor", () => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }; + const startSessionEffect = input?.startSessionEffect; const startSession = vi.fn((_: unknown, input: unknown) => { const sessionIndex = nextSessionIndex++; const resumeCursor = @@ -212,8 +217,13 @@ describe("ProviderCommandReactor", () => { createdAt: now, updatedAt: now, }; - runtimeSessions.push(session); - return Effect.succeed(session); + return (startSessionEffect?.(session) ?? Effect.succeed(session)).pipe( + Effect.tap((startedSession) => + Effect.sync(() => { + runtimeSessions.push(startedSession); + }), + ), + ); }); const sendTurn = vi.fn((_: unknown) => Effect.succeed({ @@ -463,9 +473,120 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.status).toBe("starting"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + effectIt.effect("projects starting before a slow provider session finishes", () => + Effect.gen(function* () { + const releaseStart = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => Deferred.await(releaseStart).pipe(Effect.as(session)), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-slow-provider"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-slow-provider"), + role: "user", + text: "start slowly", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1)); + const duringStartup = yield* Effect.promise(() => harness.readModel()); + expect( + duringStartup.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session + ?.status, + ).toBe("starting"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + + yield* Deferred.succeed(releaseStart, undefined); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + }), + ); + + effectIt.effect("settles a failed provider startup and allows a clean retry", () => + Effect.gen(function* () { + let failStartup = true; + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => + failStartup + ? Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.start", + detail: "deterministic startup failure", + }), + ) + : Effect.succeed(session), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-provider-failure"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-provider-failure"), + role: "user", + text: "fail once", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.session + ?.status === "error" + ); + }), + ); + let readModel = yield* Effect.promise(() => harness.readModel()); + let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.lastError).toContain("deterministic startup failure"); + expect(harness.sendTurn).not.toHaveBeenCalled(); + + failStartup = false; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-provider-retry"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-provider-retry"), + role: "user", + text: "retry", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + readModel = yield* Effect.promise(() => harness.readModel()); + thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.lastError).toBeNull(); + }), + ); + it("generates a thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..b6bff8c766a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -288,15 +288,20 @@ const make = Effect.gen(function* () { readonly createdAt: string; }) { const thread = yield* resolveThread(input.threadId); - const session = thread?.session; - if (!session) { + if (!thread) { return; } + const session = thread.session; yield* setThreadSession({ threadId: input.threadId, session: { - ...session, - status: session.status === "stopped" ? "stopped" : "ready", + ...(session ?? { + threadId: input.threadId, + providerName: null, + providerInstanceId: thread.modelSelection.instanceId, + runtimeMode: thread.runtimeMode, + }), + status: session?.status === "stopped" ? "stopped" : "error", activeTurnId: null, lastError: input.detail, updatedAt: input.createdAt, @@ -354,6 +359,7 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly modelSelection?: ModelSelection; + readonly pendingTurnStart?: boolean; }, ) { const thread = yield* resolveThread(threadId); @@ -428,6 +434,22 @@ const make = Effect.gen(function* () { }); } const preferredProvider: ProviderDriverKind = desiredDriverKind; + if (options?.pendingTurnStart === true && thread.session?.status !== "running") { + yield* setThreadSession({ + threadId, + session: { + threadId, + status: "starting", + providerName: activeSession?.provider ?? preferredProvider, + providerInstanceId: activeSession?.providerInstanceId ?? desiredInstanceId, + runtimeMode: desiredRuntimeMode, + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + } if (thread.session !== null) { yield* rejectStartedThreadModelChangeIfRequired({ threadId, @@ -498,7 +520,10 @@ const make = Effect.gen(function* () { threadId, session: { threadId, - status: mapProviderSessionStatusToOrchestrationStatus(session.status), + status: + options?.pendingTurnStart === true && session.status === "ready" + ? "starting" + : mapProviderSessionStatusToOrchestrationStatus(session.status), providerName: session.provider, providerInstanceId: session.providerInstanceId, runtimeMode: desiredRuntimeMode, @@ -597,11 +622,10 @@ const make = Effect.gen(function* () { new Error(`Thread '${input.threadId}' was not found in read model.`), ); } - yield* ensureSessionForThread( - input.threadId, - input.createdAt, - input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, - ); + yield* ensureSessionForThread(input.threadId, input.createdAt, { + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + pendingTurnStart: true, + }); if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 20611e1ee75..74ece50cd31 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -30,6 +30,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import * as PubSub from "effect/PubSub"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import { it as effectIt } from "@effect/vitest"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; @@ -493,6 +494,185 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBeNull(); }); + effectIt.effect( + "keeps a reconnecting pending turn starting while ready clears stale active state", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = asThreadId("thread-1"); + const staleTurnId = asTurnId("turn-stale-before-reconnect"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-pending-reconnect"), + threadId, + message: { + messageId: MessageId.make("message-pending-reconnect"), + role: "user", + text: "resume after reconnect", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-starting-pending-reconnect"), + threadId, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: staleTurnId, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-ready-pending-reconnect"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:02.000Z", + payload: { state: "ready" }, + }); + + let thread = yield* Effect.promise(() => + waitForThread( + harness.readModel, + (entry) => entry.session?.status === "starting" && entry.session.activeTurnId === null, + ), + ); + expect(thread.session?.status).toBe("starting"); + expect(thread.session?.activeTurnId).toBeNull(); + + harness.emit({ + type: "session.started", + eventId: asEventId("evt-session-started-pending-reconnect"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:03.000Z", + }); + yield* Effect.promise(() => harness.drain()); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + )!; + expect(thread.session?.status).toBe("starting"); + expect(thread.session?.activeTurnId).toBeNull(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-pending-reconnect"), + provider: ProviderDriverKind.make("codex"), + threadId, + turnId: asTurnId("turn-after-reconnect"), + createdAt: "2026-01-01T00:00:04.000Z", + }); + thread = yield* Effect.promise(() => + waitForThread( + harness.readModel, + (entry) => + entry.session?.status === "running" && + entry.session.activeTurnId === asTurnId("turn-after-reconnect"), + ), + ); + expect(thread.session?.status).toBe("running"); + + harness.emit({ + type: "session.started", + eventId: asEventId("evt-session-started-duplicate-midturn"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:05.000Z", + }); + yield* Effect.promise(() => harness.drain()); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + )!; + expect(thread.session?.status).toBe("running"); + expect(thread.session?.activeTurnId).toBe(asTurnId("turn-after-reconnect")); + }), + ); + + effectIt.effect("keeps an aborted pending start stopped across duplicate exit events", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = asThreadId("thread-1"); + const stoppedAt = "2026-01-01T00:00:02.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-stop"), + threadId, + message: { + messageId: MessageId.make("message-before-stop"), + role: "user", + text: "stop this startup", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-starting-before-stop"), + threadId, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-stop-pending-start"), + threadId, + session: { + threadId, + status: "stopped", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: stoppedAt, + }, + createdAt: stoppedAt, + }); + + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-session-exited-after-stop"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:03.000Z", + }); + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-duplicate-session-exited-after-stop"), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:00:04.000Z", + }); + + yield* Effect.promise(() => harness.drain()); + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.activeTurnId).toBeNull(); + }), + ); + it("does not clear active turn when session/thread started arrives mid-turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 09566feb2b2..a8a51b30260 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1308,6 +1308,11 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId: thread.id, + }); + const hasPendingTurnStart = + Option.isSome(pendingTurnStart) && thread.session?.status === "starting"; const conflictsWithActiveTurn = activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); @@ -1322,11 +1327,7 @@ const make = Effect.gen(function* () { const conflictingTurnStartIsPendingTurnStart = event.type === "turn.started" && conflictsWithActiveTurn ? sameId(yield* getExpectedProviderTurnIdForThread(thread.id), eventTurnId) && - Option.isSome( - yield* projectionTurnRepository.getPendingTurnStartByThreadId({ - threadId: thread.id, - }), - ) + Option.isSome(pendingTurnStart) : false; const shouldApplyThreadLifecycle = (() => { @@ -1370,8 +1371,10 @@ const make = Effect.gen(function* () { ) { const status = (() => { switch (event.type) { - case "session.state.changed": - return orchestrationSessionStatusFromRuntimeState(event.payload.state); + case "session.state.changed": { + const runtimeStatus = orchestrationSessionStatusFromRuntimeState(event.payload.state); + return hasPendingTurnStart && runtimeStatus === "ready" ? "starting" : runtimeStatus; + } case "turn.started": return "running"; case "session.exited": @@ -1383,8 +1386,8 @@ const make = Effect.gen(function* () { case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an - // active turn; preserve turn-running state in that case. - return activeTurnId !== null ? "running" : "ready"; + // active or pending turn; preserve that lifecycle state. + return activeTurnId !== null ? "running" : hasPendingTurnStart ? "starting" : "ready"; } })(); const nextActiveTurnId = @@ -1392,7 +1395,10 @@ const make = Effect.gen(function* () { ? (eventTurnId ?? null) : event.type === "turn.completed" || event.type === "session.exited" ? null - : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn(status) + : event.type === "session.state.changed" && + !sessionStatusAllowsActiveTurn( + orchestrationSessionStatusFromRuntimeState(event.payload.state), + ) ? null : activeTurnId; const lastError = diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 50f3c2d3912..59f9f1d6f55 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -178,6 +178,51 @@ describe("effectiveSettled", () => { ).toBe(false); }); + it("keeps a new turn active from queued through starting and running", () => { + const requestedAt = "2026-04-09T12:00:00.000Z"; + const transitionNow = "2026-04-09T12:00:30.000Z"; + const base = makeShell({ + settledOverride: null, + activityAt: STALE, + }); + const queued: OrchestrationThreadShell = { + ...base, + latestUserMessageAt: requestedAt, + latestTurn: null, + session: null, + }; + const starting: OrchestrationThreadShell = { + ...queued, + session: { + threadId: queued.id, + status: "starting", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: requestedAt, + }, + }; + const running: OrchestrationThreadShell = { + ...starting, + session: { + ...starting.session!, + status: "running", + activeTurnId: TurnId.make("turn-new"), + }, + }; + + for (const shell of [queued, starting, running]) { + expect( + effectiveSettled(shell, { + now: transitionNow, + autoSettleAfterDays: 3, + changeRequestState: "merged", + }), + ).toBe(false); + } + }); + it("uses a strict inactivity boundary and honors a null threshold", () => { const boundary = makeShell({ activityAt: "2026-04-07T00:00:00.000Z", From 4e09cddb40eb1bb1e111a0374b46e73b38ffbb29 Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:57:29 +0800 Subject: [PATCH 039/110] fix(server): stop restoring stale OpenCode models (#4095) Co-authored-by: codex --- .../provider/Layers/OpenCodeProvider.test.ts | 19 +- .../provider/Layers/ProviderRegistry.test.ts | 308 ++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 28 +- apps/server/src/provider/opencodeRuntime.ts | 38 ++- 4 files changed, 372 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 05160517bfe..41454b48b31 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -97,10 +97,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), loadInventoryFromCli: () => runtimeMock.state.inventoryError - ? Effect.succeed({ - providerList: { all: [], default: {}, connected: [] as string[] }, - agents: [], - } as OpenCodeInventory) + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), }; @@ -212,14 +215,18 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("degrades gracefully on CLI failure for local installs", () => + it.effect("reports local model inventory failures without treating them as empty", () => Effect.gen(function* () { runtimeMock.state.inventoryError = new Error("opencode models failed"); const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, true); NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: opencode models failed", + ); }), ); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 159d853121c..5efbb6f1c14 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -563,6 +563,176 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("drops stale OpenCode models missing from a successful refresh", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + + it("retains stale OpenCode models when a refresh fails", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const pendingProvider = { + ...previousProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:01:00.000Z", + version: null, + models: [], + message: "OpenCode provider status has not been checked in this session yet.", + } satisfies ServerProvider; + const loggedOutProvider = { + ...previousProvider, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "OpenCode is available, but it did not report any connected upstream providers.", + } satisfies ServerProvider; + const missingProvider = { + ...previousProvider, + status: "error", + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:03:00.000Z", + version: null, + models: [], + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + } satisfies ServerProvider; + const authoritativeProvider = { + ...previousProvider, + checkedAt: "2026-07-17T00:04:00.000Z", + models: [previousProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:05:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ + ...previousProvider.models, + ]); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, loggedOutProvider).models, + [], + ); + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); + + const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); + const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); + + assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); + }); + it("fills missing capabilities from the previous provider snapshot", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), @@ -866,6 +1036,144 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect( + "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", + () => + Effect.gen(function* () { + const openCodeDriver = ProviderDriverKind.make("opencode"); + const openCodeInstanceId = ProviderInstanceId.make("opencode"); + const initialProvider = { + instanceId: openCodeInstanceId, + driver: openCodeDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-17T00:00:00.000Z", + version: "1.0.0", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-plugin/model", + name: "Removed Plugin Model", + subProvider: "Removed Plugin", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const authoritativeProvider = { + ...initialProvider, + checkedAt: "2026-07-17T00:01:00.000Z", + models: [initialProvider.models[0]!], + } satisfies ServerProvider; + const failedProvider = { + ...authoritativeProvider, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-07-17T00:02:00.000Z", + models: [], + message: "Failed to refresh OpenCode models.", + } satisfies ServerProvider; + const changes = yield* PubSub.unbounded(); + const instance = { + instanceId: openCodeInstanceId, + driverKind: openCodeDriver, + continuationIdentity: { + driverKind: openCodeDriver, + continuationKey: "opencode:instance:opencode", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: openCodeDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(authoritativeProvider), + streamChanges: Stream.fromPubSub(changes), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-opencode-authoritative-persist-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: openCodeInstanceId, + }); + + yield* PubSub.publish(changes, authoritativeProvider); + + let cachedProvider = yield* readProviderStatusCache(filePath); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== authoritativeProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + + yield* PubSub.publish(changes, failedProvider); + for ( + let attempt = 0; + attempt < 50 && cachedProvider?.checkedAt !== failedProvider.checkedAt; + attempt += 1 + ) { + yield* TestClock.adjust("10 millis"); + yield* Effect.yieldNow; + cachedProvider = yield* readProviderStatusCache(filePath); + } + + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ + authoritativeProvider.models[0]!, + ]); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("returns the cached provider list when a manual refresh fails", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e53830..760c8e1c59e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -78,11 +78,31 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { + if (provider.driver !== ProviderDriverKind.make("opencode")) { + return true; + } + + // OpenCode's initial snapshot is deliberately non-authoritative while its + // first probe is still running. A probe error from an installed CLI/server + // is likewise partial: it could not establish the current inventory. + // Conversely, disabled and missing-CLI snapshots are authoritative removals, + // as are successful ready/warning inventories (including an empty one after + // logout or plugin removal). + const isPendingInitialProbe = + provider.enabled && !provider.installed && provider.status === "warning"; + const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; + return isPendingInitialProbe || didInstalledProviderProbeFail; +}; + const mergeProviderModels = ( + provider: ServerProvider, previousModels: ReadonlyArray, nextModels: ReadonlyArray, ): ReadonlyArray => { - if (nextModels.length === 0 && previousModels.length > 0) { + const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); + + if (shouldRetainMissingModels && nextModels.length === 0 && previousModels.length > 0) { return previousModels; } @@ -98,7 +118,9 @@ const mergeProviderModels = ( }; }); const nextSlugs = new Set(nextModels.map((model) => model.slug)); - return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; + return shouldRetainMissingModels + ? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))] + : mergedModels; }; export const mergeProviderSnapshot = ( @@ -109,7 +131,7 @@ export const mergeProviderSnapshot = ( ? nextProvider : { ...nextProvider, - models: mergeProviderModels(previousProvider.models, nextProvider.models), + models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), }; export const mergeProviderSnapshots = ( diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index b853662b037..63fcea22d19 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -690,22 +690,36 @@ const makeOpenCodeRuntime = Effect.gen(function* () { agentsResult = a2; } - // Degrade gracefully on failure — return empty inventory (warning status, not error) - let connected: string[] = []; - let allProviders: ProviderListResponse["all"] = []; - if (modelsResult._tag === "Success" && modelsResult.value.code === 0) { - const parsed = parseModelsCliOutput(modelsResult.value.stdout); - connected = [...parsed.connected]; - allProviders = [...parsed.providers.values()].map((p) => ({ - id: p.id, - name: p.name, + if (modelsResult._tag === "Failure") { + const cause = Cause.squash(modelsResult.cause); + return yield* ensureRuntimeError( + "loadInventoryFromCli", + `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + ); + } + if (modelsResult.value.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, + }); + } + + const parsed = parseModelsCliOutput(modelsResult.value.stdout); + const connected = [...parsed.connected]; + const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( + (provider) => ({ + id: provider.id, + name: provider.name, source: "config" as const, env: [], options: {}, - models: p.models, - })); - } + models: provider.models, + }), + ); + // Agent metadata enriches model capabilities but is not required for an + // authoritative model inventory, so it may still degrade to an empty list. let agents: ReadonlyArray = []; if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { agents = parseAgentListCliOutput(agentsResult.value.stdout); From c7b21ff172f8e64318ec681e8839ee60ca660fa4 Mon Sep 17 00:00:00 2001 From: Maxwell Young Date: Thu, 23 Jul 2026 01:57:39 +1200 Subject: [PATCH 040/110] [codex] keep scoped package references as text (#4167) Co-authored-by: codex Co-authored-by: Julius Marminge --- .../components/ComposerPromptEditor.test.ts | 79 +++++++++++++++++++ apps/web/src/composer-editor-mentions.test.ts | 29 ++++++- apps/web/src/composer-logic.test.ts | 13 ++- .../shared/src/composerInlineTokens.test.ts | 48 +++++++++++ packages/shared/src/composerInlineTokens.ts | 5 +- 5 files changed, 169 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 45a2db4731c..0aab8fb0c2d 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -70,4 +70,83 @@ describe("registerComposerInlineTokenPaste", () => { " ", ); }); + + it.each([ + "yarn expo install @expo/ui", + "npm install @jane/foo.js", + "import '@scope/pkg/sub/path'", + ])("leaves scoped package command %s to the plain-text paste fallback", (command) => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor(); + const plainTextFallback = vi.fn((event: ClipboardEvent) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return false; + selection.insertText(event.clipboardData?.getData("text/plain") ?? ""); + return true; + }); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + }); + editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); + + const event = new TestClipboardEvent(command); + let handled = false; + editor.update( + () => { + handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + + expect(handled).toBe(true); + expect(plainTextFallback).toHaveBeenCalledOnce(); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(command); + }); + + it("pastes a canonical scoped folder link as a mention", () => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor(); + const mention = "[sub](@scope/pkg/sub)"; + const plainTextFallback = vi.fn(() => true); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + }); + editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); + + const event = new TestClipboardEvent(mention); + let handled = false; + editor.update( + () => { + handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + + expect(handled).toBe(true); + expect(plainTextFallback).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + " ", + ); + }); }); diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index d79170769d5..70c97400370 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -22,9 +22,9 @@ describe("splitPromptIntoComposerSegments", () => { }); it("keeps newlines around mention tokens", () => { - expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([ + expect(splitPromptIntoComposerSegments("one\n@AGENTS.md \ntwo")).toEqual([ { type: "text", text: "one\n" }, - { type: "mention", path: "src/index.ts", source: "@src/index.ts" }, + { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" }, { type: "text", text: " \ntwo" }, ]); }); @@ -71,6 +71,31 @@ describe("splitPromptIntoComposerSegments", () => { ).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]); }); + it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])( + "does not turn scoped package reference %s into file mention segments", + (reference) => { + const prompt = `Install ${reference} next`; + expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]); + }, + ); + + it("keeps IME-composed text containing a scoped package reference as text", () => { + const prompt = "入力 @expo/ui を追加"; + expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]); + }); + + it("turns canonical scoped folder links into file mention segments", () => { + expect(splitPromptIntoComposerSegments("Inspect [sub](@scope/pkg/sub) next")).toEqual([ + { type: "text", text: "Inspect " }, + { + type: "mention", + path: "@scope/pkg/sub", + source: "[sub](@scope/pkg/sub)", + }, + { type: "text", text: " next" }, + ]); + }); + it("decodes reserved path characters from generated links", () => { expect( splitPromptIntoComposerSegments( diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 99ac6bba716..b8ef7443611 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -246,12 +246,21 @@ describe("collapseExpandedComposerCursor", () => { ); }); - it("keeps replacement cursors aligned when another mention already exists earlier", () => { + it("keeps package-like text expanded when another mention already exists earlier", () => { const text = "open @AGENTS.md then @src/index.ts "; const expandedCursor = text.length; const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); - expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2); + expect(collapsedCursor).toBe("open ".length + 1 + " then @src/index.ts ".length); + expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); + }); + + it("collapses only genuine mentions when package-like text exists earlier", () => { + const text = "install @scope/pkg then @README.md "; + const expandedCursor = text.length; + const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); + + expect(collapsedCursor).toBe("install @scope/pkg then ".length + 1 + " ".length); expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); }); diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index f99d0b6654e..5a7c14f1725 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -81,4 +81,52 @@ describe("collectComposerInlineTokens", () => { it("ignores normal web links", () => { expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]); }); + + it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])( + "keeps scoped package reference %s as plain text", + (reference) => { + expect(collectComposerInlineTokens(`Install ${reference} next`)).toEqual([]); + }, + ); + + it("keeps scoped package references plain across incomplete input and IME whitespace", () => { + expect(collectComposerInlineTokens("Install @expo/ui")).toEqual([]); + expect(collectComposerInlineTokens("入力 @expo/ui を追加")).toEqual([]); + }); + + it("keeps bare non-scoped file paths as mentions", () => { + expect(collectComposerInlineTokens("Inspect @README.md next")).toEqual([ + { + type: "mention", + value: "README.md", + source: "@README.md", + start: 8, + end: 18, + }, + ]); + }); + + it("keeps canonical file links for scoped paths as mentions", () => { + expect(collectComposerInlineTokens("Inspect [sub](@scope/pkg/sub) next")).toEqual([ + { + type: "mention", + value: "@scope/pkg/sub", + source: "[sub](@scope/pkg/sub)", + start: 8, + end: 29, + }, + ]); + }); + + it("allows ambiguous scoped paths through explicit quoted mentions", () => { + expect(collectComposerInlineTokens('Inspect @"expo/ui" next')).toEqual([ + { + type: "mention", + value: "expo/ui", + source: '@"expo/ui"', + start: 8, + end: 18, + }, + ]); + }); }); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index aa5e67d6fc8..dda548059df 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -23,6 +23,9 @@ const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; +// Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package. +const SCOPED_PACKAGE_REFERENCE_REGEX = + /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/; function collectMentionTokens(text: string): ComposerInlineToken[] { const matches: ComposerInlineToken[] = []; @@ -60,7 +63,7 @@ function collectMentionTokens(text: string): ComposerInlineToken[] { const prefix = match[1] ?? ""; const quotedPath = match[2]; const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); - if (!path) { + if (!path || (quotedPath === undefined && SCOPED_PACKAGE_REFERENCE_REGEX.test(path))) { continue; } const start = (match.index ?? 0) + prefix.length; From b6e1b39335ffd34583c94f5b1e5e2c80520729fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 22 Jul 2026 15:58:27 +0200 Subject: [PATCH 041/110] fix(web): default provider selection for users without Codex (#4117) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/ChatView.tsx | 14 +- apps/web/src/components/CommandPalette.tsx | 21 +- apps/web/src/components/chat/ChatComposer.tsx | 157 +++++++---- apps/web/src/providerInstances.test.ts | 258 +++++++++++++++++- apps/web/src/providerInstances.ts | 93 ++++++- .../src/operations/projects.test.ts | 6 +- .../client-runtime/src/operations/projects.ts | 6 +- 7 files changed, 458 insertions(+), 97 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f3145df4500..04bd3a97c05 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -152,6 +152,7 @@ import { } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; +import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -1361,10 +1362,7 @@ function ChatViewContent(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], @@ -1856,7 +1854,7 @@ function ChatViewContent(props: ChatViewProps) { const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), + selectedProviderByThreadId ?? threadProvider, ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); @@ -4038,7 +4036,7 @@ function ChatViewContent(props: ChatViewProps) { return; } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) return; + if (!sendCtx?.providerAvailable) return; const { images: composerImages, terminalContexts: composerTerminalContexts, @@ -4614,7 +4612,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) { + if (!sendCtx?.providerAvailable) { return; } const { @@ -4773,7 +4771,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) { + if (!sendCtx?.providerAvailable) { return; } const { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..9d824e0f72d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -7,12 +7,10 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { - DEFAULT_MODEL, type DesktopWslState, type EnvironmentId, type FilesystemBrowseResult, type ProjectId, - ProviderInstanceId, type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, @@ -111,7 +109,8 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; +import { resolveDefaultProviderModelSelection } from "../providerInstances"; import { resolveShortcutCommand } from "../keybindings"; import { Command, @@ -476,6 +475,7 @@ function OpenCommandPaletteDialog(props: { const projects = useProjects(); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -1149,6 +1149,10 @@ function OpenCommandPaletteDialog(props: { } const projectId = newProjectId(); + const targetEnvironmentProviders = + environments.find((environment) => environment.environmentId === input.environmentId) + ?.serverConfig?.providers ?? + (input.environmentId === primaryEnvironmentId ? providers : []); const createResult = await createProject({ environmentId: input.environmentId, input: { @@ -1156,10 +1160,10 @@ function OpenCommandPaletteDialog(props: { title: inferProjectTitleFromPath(cwd), workspaceRoot: cwd, createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + defaultModelSelection: resolveDefaultProviderModelSelection( + targetEnvironmentProviders, + null, + ), }, }); if (createResult._tag === "Failure") { @@ -1195,8 +1199,11 @@ function OpenCommandPaletteDialog(props: { [ handleNewThread, createProject, + environments, navigate, + primaryEnvironmentId, projects, + providers, setOpen, clientSettings.sidebarThreadSortOrder, threads, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 94e4af3bba6..591a07ac4c5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -114,7 +114,9 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, resolveProviderDriverKindForInstanceSelection, + resolveSelectableProviderInstanceEntry, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -433,6 +435,7 @@ export interface ChatComposerHandle { selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; + providerAvailable: boolean; selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray; @@ -696,8 +699,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerInstanceEntries, providerStatuses, explicitSelectedInstanceId, - ) ?? ProviderDriverKind.make("codex"); - const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + ) ?? + providerInstanceEntries[0]?.driverKind ?? + ProviderDriverKind.make("unconfigured"); + const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const lockedContinuationGroupKey = useMemo((): string | null => { if (!lockedProvider || !activeThread) return null; const lockedInstanceId = @@ -734,7 +739,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) for (const candidate of candidates) { if (!candidate) continue; const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled, + (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, ); if (match) { // When locked to a specific driver kind, ignore persisted instance @@ -749,36 +754,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return match.instanceId; } } - if (explicitSelectedInstanceId) { - return ProviderInstanceId.make(explicitSelectedInstanceId); - } - const byKind = providerInstanceEntries.find( + const compatibleEntries = providerInstanceEntries.filter( (entry) => - entry.enabled && - entry.driverKind === selectedProvider && + (!lockedProvider || entry.driverKind === lockedProvider) && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), ); - if (byKind) return byKind.instanceId; - const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); + const requestedDriverEntries = compatibleEntries.filter( + (entry) => entry.driverKind === requestedDriverKind, + ); return ( - anyEnabled?.instanceId ?? - providerInstanceEntries[0]?.instanceId ?? - activeThreadModelSelection?.instanceId ?? - activeProjectDefaultModelSelection?.instanceId ?? - ProviderInstanceId.make("codex") + resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? + resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? + NO_PROVIDER_MODEL_SELECTION.instanceId ); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, composerDraft.activeProvider, - explicitSelectedInstanceId, lockedContinuationGroupKey, lockedProvider, providerInstanceEntries, - selectedProvider, + requestedDriverKind, ]); + // Resolve the active instance's snapshot by `instanceId` so a custom + // instance gets its own slash commands, skills, and model list — not + // the first snapshot for the same driver kind. + const selectedProviderEntry = useMemo( + () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), + [providerInstanceEntries, selectedInstanceId], + ); + const noProviderAvailable = selectedProviderEntry === undefined; + // The driver kind follows the instance that will actually run the turn, + // which can differ from the persisted selection when that selection is + // disabled. + const selectedProvider: ProviderDriverKind = + selectedProviderEntry?.driverKind ?? requestedDriverKind; + const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, providers: providerStatuses, @@ -788,14 +801,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectModelSelection: activeProjectDefaultModelSelection, settings, }); - - // Resolve the active instance's snapshot by `instanceId` so a custom - // instance gets its own slash commands, skills, and model list — not - // the first snapshot for the same driver kind. - const selectedProviderEntry = useMemo( - () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), - [providerInstanceEntries, selectedInstanceId], - ); const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], @@ -1158,6 +1163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) phase === "running" || isSendBusy || isConnecting || + noProviderAvailable || projectSelectionRequired || environmentUnavailable !== null || !composerSendState.hasSendableContent; @@ -1698,7 +1704,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; - if (isSendBusy || isConnecting || environmentUnavailable !== null || phase === "running") { + if ( + isSendBusy || + isConnecting || + noProviderAvailable || + environmentUnavailable !== null || + phase === "running" + ) { return false; } if (activePendingProgress) { @@ -1713,18 +1725,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting, isMobileViewport, isSendBusy, + noProviderAvailable, phase, showPlanFollowUpPrompt, ]); const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { + if (noProviderAvailable) { + event?.preventDefault(); + return; + } onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, - [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], + [blurMobileComposerAfterSend, noProviderAvailable, onSend, shouldBlurMobileComposerOnSubmit], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -2083,6 +2100,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, + providerAvailable: !noProviderAvailable, selectedProvider, selectedModel, selectedProviderModels, @@ -2110,6 +2128,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, + noProviderAvailable, selectedPromptEffort, selectedProvider, selectedProviderModels, @@ -2260,7 +2279,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy={isSendBusy} isConnecting={isConnecting} isEnvironmentUnavailable={ - environmentUnavailable !== null || projectSelectionRequired + environmentUnavailable !== null || + noProviderAvailable || + projectSelectionRequired } isPreparingWorktree={false} hasSendableContent={false} @@ -2292,7 +2313,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {activePendingProgress ? activePendingProgress.customAnswer || "Type your own answer, or leave this blank to use the selected option" - : prompt.trim() || "Ask anything..."} + : prompt.trim() || + (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} + ) : ( + { + setIsComposerModelPickerOpen(open); + }} + getModelDisabledReason={getModelDisabledReason} + onInstanceModelChange={onProviderModelSelect} + /> + )} {isComposerFooterCompact ? ( ({ + slug, + name: slug, + isCustom, + ...(isDefault ? { isDefault: true } : {}), + capabilities: {}, +}); + describe("isProviderInstancePickerReady", () => { it("rejects a disabled instance even while its last probe status is ready", () => { const [entry] = deriveProviderInstanceEntries([ @@ -146,6 +158,67 @@ describe("resolveSelectableProviderInstance", () => { expect(resolveSelectableProviderInstance(providers, disabled)).toBe(fallback); }); + it("prefers a ready instance over an enabled one whose driver cannot start", () => { + const notInstalled = ProviderInstanceId.make("codex"); + const ready = ProviderInstanceId.make("claudeAgent"); + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: notInstalled, + status: "error", + }), + provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: ready }), + ]; + + expect(resolveSelectableProviderInstance(providers, undefined)).toBe(ready); + }); + + it("prefers an unprobed (warning) instance over one whose probe errored", () => { + const notInstalled = ProviderInstanceId.make("codex"); + const unprobed = ProviderInstanceId.make("claudeAgent"); + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: notInstalled, + status: "error", + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: unprobed, + status: "warning", + }), + ]; + + expect(resolveSelectableProviderInstance(providers, undefined)).toBe(unprobed); + }); + + it("keeps a requested instance even when its probe errored", () => { + const requested = ProviderInstanceId.make("codex"); + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: requested, + status: "error", + }), + provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), + ]; + + expect(resolveSelectableProviderInstance(providers, requested)).toBe(requested); + }); + + it("does not invent an errored instance as a new-user default", () => { + const notInstalled = ProviderInstanceId.make("codex"); + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: notInstalled, + status: "error", + }), + ]; + + expect(resolveSelectableProviderInstance(providers, undefined)).toBeUndefined(); + }); + it("does not return disabled, unavailable, or unknown instances when none are sendable", () => { const disabled = ProviderInstanceId.make("codex"); const unavailable = ProviderInstanceId.make("claudeAgent"); @@ -206,3 +279,184 @@ describe("resolveProviderDriverKindForInstanceSelection", () => { ).toBeUndefined(); }); }); + +describe("getDefaultProviderInstanceModel", () => { + it("uses the instance's own models, not the default instance of the kind", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claude_openrouter", + models: [model("openai/gpt-5.5", true), model("claude-opus-4-8")], + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-sonnet-5")], + }), + ]; + + expect( + getDefaultProviderInstanceModel(providers, ProviderInstanceId.make("claude_openrouter")), + ).toBe("claude-opus-4-8"); + }); + + it("falls back to the driver default when the instance reports no models", () => { + const providers = [ + provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), + ]; + + const resolved = getDefaultProviderInstanceModel( + providers, + ProviderInstanceId.make("claudeAgent"), + ); + expect(typeof resolved).toBe("string"); + expect(resolved?.length).toBeGreaterThan(0); + }); + + it("honors the instance's declared default before model-list order", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-sonnet-5"), model("claude-opus-4-8", false, true)], + }), + ]; + + expect(getDefaultProviderInstanceModel(providers, ProviderInstanceId.make("claudeAgent"))).toBe( + "claude-opus-4-8", + ); + }); + + it("returns undefined for an unknown instance", () => { + expect( + getDefaultProviderInstanceModel([], ProviderInstanceId.make("removed_instance")), + ).toBeUndefined(); + }); +}); + +describe("resolveDefaultProviderModelSelection", () => { + it.each([ + ["codex", "codex", "gpt-5.6"], + ["claudeAgent", "claudeAgent", "claude-fable-5"], + ["cursor", "cursor", "composer-2"], + ])("uses the only available %s instance", (driver, instanceId, modelSlug) => { + const providers = [ + provider({ + provider: ProviderDriverKind.make(driver), + instanceId, + models: [model(modelSlug, false, true)], + }), + ]; + + expect(resolveDefaultProviderModelSelection(providers, null)).toEqual({ + instanceId, + model: modelSlug, + }); + }); + + it("preserves a valid stored selection including its options", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-opus-4-8")], + }), + ]; + const stored = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "custom-model", + options: [{ id: "effort", value: "high" }], + }; + + expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored); + }); + + it("replaces a stale stored instance with the first ready instance and its model", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + status: "warning", + models: [model("gpt-5.6")], + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-opus-4-8", false, true)], + }), + ]; + + expect( + resolveDefaultProviderModelSelection(providers, { + instanceId: ProviderInstanceId.make("removed-provider"), + model: "stale-model", + }), + ).toEqual({ instanceId: "claudeAgent", model: "claude-opus-4-8" }); + }); + + it.each([{ enabled: false }, { availability: "unavailable" as const }])( + "replaces an unavailable stored instance deterministically", + (requestedState) => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + models: [model("gpt-5.6")], + ...requestedState, + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-opus-4-8", false, true)], + }), + ]; + + expect( + resolveDefaultProviderModelSelection(providers, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6", + }), + ).toEqual({ instanceId: "claudeAgent", model: "claude-opus-4-8" }); + }, + ); + + it("returns no selection for empty, disabled, unavailable, or error-only profiles", () => { + expect(resolveDefaultProviderModelSelection([], null)).toBeNull(); + expect( + resolveDefaultProviderModelSelection( + [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + enabled: false, + }), + ], + null, + ), + ).toBeNull(); + expect( + resolveDefaultProviderModelSelection( + [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + availability: "unavailable", + }), + ], + null, + ), + ).toBeNull(); + expect( + resolveDefaultProviderModelSelection( + [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + status: "error", + }), + ], + null, + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index c9ac87ac39f..337e68d44d0 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -13,10 +13,12 @@ * @module providerInstances */ import { + DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, PROVIDER_DISPLAY_NAMES, + type ModelSelection, type ProviderDriverKind, - type ProviderInstanceId, + ProviderInstanceId, type ServerProvider, type ServerProviderModel, type ServerSettings, @@ -25,6 +27,16 @@ import { import { formatProviderDriverKindLabel } from "./providerModels"; +/** + * Local-only placeholder used while a draft has no provider it can safely + * target. It must never be persisted or dispatched; the composer disables + * send until a live provider replaces it. + */ +export const NO_PROVIDER_MODEL_SELECTION: ModelSelection = { + instanceId: ProviderInstanceId.make("t3code_no_provider"), + model: "", +}; + /** * UI-facing projection of one configured provider instance. Carries the * snapshot verbatim for callers that need server-side fields we don't @@ -253,27 +265,82 @@ export function getProviderInstanceModels( return getProviderInstanceEntry(providers, instanceId)?.models ?? []; } +/** + * Default model slug for a specific instance: its declared built-in default, + * then its first built-in model, then any model it reports, then the driver-level default. Custom + * instances can serve a different model list than the default instance of + * the same driver kind, so the lookup must be instance-scoped rather than + * kind-scoped. + */ +export function getDefaultProviderInstanceModel( + providers: ReadonlyArray, + instanceId: ProviderInstanceId, +): string | undefined { + const entry = getProviderInstanceEntry(providers, instanceId); + if (!entry) return undefined; + return ( + entry.models.find((model) => model.isDefault && !model.isCustom)?.slug ?? + entry.models.find((model) => !model.isCustom)?.slug ?? + entry.models[0]?.slug ?? + DEFAULT_MODEL_BY_PROVIDER[entry.driverKind] + ); +} + +const isSelectableProviderInstanceEntry = (entry: ProviderInstanceEntry): boolean => + entry.enabled && entry.isAvailable; + +/** + * Resolve an exact stored instance when it remains enabled and available. + * Otherwise choose a deterministic fallback that can plausibly start now: + * ready first, then a non-error probe result. An errored provider is retained + * only when it was explicitly requested; it is never invented as a new-user + * default. + */ +export function resolveSelectableProviderInstanceEntry( + entries: ReadonlyArray, + instanceId: ProviderInstanceId | undefined, +): ProviderInstanceEntry | undefined { + if (instanceId !== undefined) { + const requested = entries.find((entry) => entry.instanceId === instanceId); + if (requested && isSelectableProviderInstanceEntry(requested)) { + return requested; + } + } + return ( + entries.find(isProviderInstancePickerReady) ?? + entries.find((entry) => isSelectableProviderInstanceEntry(entry) && entry.status !== "error") + ); +} + /** * Resolve the routing key for a selection that may reference an instance * id that no longer exists (e.g. a persisted thread selection after the - * user deleted the custom instance). Returns the first enabled instance - * as a fallback so downstream code can still send a turn. + * user deleted the custom instance). Returns a ready or non-error fallback, + * or `undefined` when no provider can safely become a new selection. */ export function resolveSelectableProviderInstance( providers: ReadonlyArray, instanceId: ProviderInstanceId | undefined, ): ProviderInstanceId | undefined { - if (instanceId === undefined) { - return deriveProviderInstanceEntries(providers).find( - (entry) => entry.enabled && entry.isAvailable, - )?.instanceId; - } const entries = deriveProviderInstanceEntries(providers); - const requested = entries.find((entry) => entry.instanceId === instanceId); - if (requested && requested.enabled && requested.isAvailable) { - return instanceId; - } - return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; + return resolveSelectableProviderInstanceEntry(entries, instanceId)?.instanceId; +} + +/** + * Resolve the model selection persisted for a project or new thread. A valid + * stored selection is preserved byte-for-byte. Falling back to another + * instance also resets the model to that instance's own default, avoiding + * cross-provider instance/model pairs. + */ +export function resolveDefaultProviderModelSelection( + providers: ReadonlyArray, + selection: ModelSelection | null | undefined, +): ModelSelection | null { + const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId); + if (instanceId === undefined) return null; + if (selection?.instanceId === instanceId) return selection; + const model = getDefaultProviderInstanceModel(providers, instanceId); + return model ? { instanceId, model } : null; } /** diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index f3bc72603ac..11b49742460 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; import { - DEFAULT_MODEL, EnvironmentId, ProjectId, CommandId, @@ -138,10 +137,7 @@ describe("add project shared logic", () => { title: "repo", workspaceRoot: "/work/repo", createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: "codex", - model: DEFAULT_MODEL, - }, + defaultModelSelection: null, }); }); }); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index ec58418a94f..6ae6e18baa2 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -7,7 +7,6 @@ import type { SourceControlProviderKind, SourceControlRepositoryInfo, } from "@t3tools/contracts"; -import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; @@ -215,10 +214,7 @@ export function buildProjectCreateCommand(input: { title: inferProjectTitleFromPath(input.workspaceRoot), workspaceRoot: input.workspaceRoot, createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + defaultModelSelection: null, createdAt: input.createdAt, }; } From 571a8b44bdd4f0f0b0464ef24240021689616e7d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 16:19:01 +0200 Subject: [PATCH 042/110] Unify temporary worktree branch naming (#4278) --- packages/shared/src/git.test.ts | 23 +++++++++++++++++++++++ packages/shared/src/git.ts | 15 +++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 80578e262f9..96539f0aae2 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -71,6 +71,29 @@ describe("isTemporaryWorktreeBranch", () => { expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); }); + it("normalizes a UUID-shaped random callback to the canonical 8-hex form", () => { + expect(buildTemporaryWorktreeBranchName(() => "f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe( + `${WORKTREE_BRANCH_PREFIX}/f4ae4e0e`, + ); + }); + + it("matches legacy UUID-shaped temporary worktree refs from older mobile builds", () => { + expect( + isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12`), + ).toBe(true); + }); + + it("rejects UUID-shaped refs that are not RFC 4122 v4", () => { + // version nibble is not 4 + expect( + isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-1d48-b4f2-9cf0aa54ab12`), + ).toBe(false); + // variant nibble is not [89ab] + expect( + isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-c4f2-9cf0aa54ab12`), + ).toBe(false); + }); + it("rejects non-temporary refName names", () => { expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); expect(isTemporaryWorktreeBranch("main")).toBe(false); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index ae50b148835..71fe2e806cf 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -11,7 +11,13 @@ import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; -const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +// Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` +// via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly +// that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads +// eligible for branch regeneration without loosening beyond what was ever generated. +const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( + `^${WORKTREE_BRANCH_PREFIX}\\/(?:[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, +); /** * Sanitize an arbitrary string into a valid, lowercase git refName fragment. @@ -89,7 +95,12 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { export function buildTemporaryWorktreeBranchName( randomHex: (byteLength: number) => string, ): string { - const token = randomHex(4).toLowerCase(); + // Normalize to exactly 8 lowercase hex chars so a UUID-shaped callback + // still produces the canonical temporary branch form. + const token = randomHex(4) + .toLowerCase() + .replace(/[^0-9a-f]/g, "") + .slice(0, 8); return `${WORKTREE_BRANCH_PREFIX}/${token}`; } From 020179c19a0073d130c30083c7623cf1196688e7 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:50:20 +0530 Subject: [PATCH 043/110] fix(web): use message-square icon for settled icon-less project threads in sidebar v2 (#4279) --- apps/web/src/components/ProjectFavicon.tsx | 30 ++++++++++++++++++---- apps/web/src/components/SidebarV2.tsx | 2 ++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index f7603a36e9a..bc3e8ee832f 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,6 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { FolderIcon } from "lucide-react"; +import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; @@ -10,29 +11,46 @@ export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; className?: string | undefined; + fallbackIcon?: ComponentType<{ className?: string }>; }) { const src = useAssetUrl(input.environmentId, { _tag: "project-favicon", cwd: input.cwd, }); + const FallbackIcon = input.fallbackIcon ?? FolderIcon; if (!src || isProjectFaviconFallbackUrl(src)) { - return ; + return ; } - return ; + return ( + + ); } -function ProjectFaviconFallback({ className }: { readonly className?: string | undefined }) { - return ; +function ProjectFaviconFallback({ + className, + icon: Icon, +}: { + readonly className?: string | undefined; + readonly icon: ComponentType<{ className?: string }>; +}) { + return ; } function ProjectFaviconImage({ src, className, + fallbackIcon: FallbackIcon, }: { readonly src: string; readonly className?: string | undefined; + readonly fallbackIcon: ComponentType<{ className?: string }>; }) { const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", @@ -40,7 +58,9 @@ function ProjectFaviconImage({ return ( <> - {status !== "loaded" ? : null} + {status !== "loaded" ? ( + + ) : null} {title} From 18b468871e6a3bfad207109898011ea330afeadb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 16:21:45 +0200 Subject: [PATCH 044/110] Stabilize sidebar settling animations (#4280) --- apps/web/src/components/SidebarV2.tsx | 44 ++++++++++++++++++--------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f3f03dc8b61..352a2fc471e 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -129,10 +129,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; - // Marks where active work transitions into history: a quiet labeled - // rule above the first settled row, so the tail reads as a named zone - // rather than an unexplained gap. - showSettledGap?: boolean; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -398,12 +394,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { data-thread-item className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]" > - {props.showSettledGap ? ( -
    - Settled - -
    - ) : null}
      - {orderedThreads.map((thread, threadIndex) => { + {orderedThreads.flatMap((thread, threadIndex) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const isSettledRow = settledThreadKeys.has(threadKey); // Settled is the ONLY thing that collapses a row: every @@ -1544,10 +1534,14 @@ export default function SidebarV2() { scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), ); const showSettledGap = !isCard && previousWasCard; - return ( + const row = ( ); + if (!showSettledGap) return [row]; + // The divider is its own keyed list item (not part of the first + // settled row): it keeps one stable DOM node at the boundary, + // so settling a thread slides it instead of teleporting it + // along with whichever row happens to be first in the tail — + // and row heights stay independent of neighbor classification. + return [ +
    • +
      + + Settled + + +
      +
    • , + row, + ]; })} {hiddenSettledCount > 0 ? (
    • From e5fba263e6820350e4edc7e5d93955aabef53df1 Mon Sep 17 00:00:00 2001 From: Henry Zhang <113233555+caezium@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:35:38 +0800 Subject: [PATCH 045/110] Restore Copy Link in chat link context menu (#4161) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatMarkdown.tsx | 61 ++++----- .../chat/externalLinkContextMenu.test.ts | 128 ++++++++++++++++++ .../chat/externalLinkContextMenu.ts | 78 +++++++++++ 3 files changed, 230 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/components/chat/externalLinkContextMenu.test.ts create mode 100644 apps/web/src/components/chat/externalLinkContextMenu.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c85340c715b..fac3bf7d245 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -42,6 +42,10 @@ import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { + resolveExternalWebLinkHost, + showExternalLinkContextMenu, +} from "./chat/externalLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; @@ -76,6 +80,7 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isBrowserPreviewFile, @@ -831,17 +836,6 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ const failedFaviconHosts = new Set(); -function resolveExternalLinkHost(href: string | undefined): string | null { - if (!href) return null; - try { - const url = new URL(href); - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - return url.hostname || null; - } catch { - return null; - } -} - const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( @@ -1393,7 +1387,7 @@ function ChatMarkdown({ const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { - const faviconHost = resolveExternalLinkHost(href); + const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -1410,37 +1404,30 @@ function ChatMarkdown({ } }} onContextMenu={(event) => { - if (!canOpenInPreview || !href) return; + if (!canOpenInPreview || !href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; - void (async () => { - let operation = "show-link-context-menu"; - try { - const clicked = await api.contextMenu.show( - [ - { id: "open-in-browser", label: "Open in integrated browser" }, - { id: "open-external", label: "Open in system browser" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ); - if (clicked === "open-in-browser") { - operation = "open-link-in-preview"; - const result = await openExternalLinkInPreview(href); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure({ operation, target: href }, result.cause); - } - return; + void showExternalLinkContextMenu({ + href, + position: { x: event.clientX, y: event.clientY }, + showContextMenu: (items, position) => api.contextMenu.show(items, position), + openInPreview: async (target) => { + const result = await openExternalLinkInPreview(target); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); } - if (clicked === "open-external") { - operation = "open-link-external"; - await api.shell.openExternal(href); - } - } catch (cause) { + }, + openExternal: (target) => api.shell.openExternal(target), + copyLink: (target) => writeTextToClipboard(target, "link"), + reportFailure: (operation, cause) => { reportMarkdownActionFailure({ operation, target: href }, cause); - } - })(); + }, + }); }} > {faviconHost ? ( diff --git a/apps/web/src/components/chat/externalLinkContextMenu.test.ts b/apps/web/src/components/chat/externalLinkContextMenu.test.ts new file mode 100644 index 00000000000..64935d53e46 --- /dev/null +++ b/apps/web/src/components/chat/externalLinkContextMenu.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { resolveExternalWebLinkHost, showExternalLinkContextMenu } from "./externalLinkContextMenu"; + +function createHarness(selection: "open-in-preview" | "open-external" | "copy-link" | null) { + const showContextMenu = vi.fn().mockResolvedValue(selection); + const openInPreview = vi.fn().mockResolvedValue(undefined); + const openExternal = vi.fn().mockResolvedValue(undefined); + const copyLink = vi.fn().mockResolvedValue(undefined); + const reportFailure = vi.fn(); + + return { + showContextMenu, + openInPreview, + openExternal, + copyLink, + reportFailure, + }; +} + +describe("external chat link context menu", () => { + it("offers both open actions and Copy Link", async () => { + const harness = createHarness(null); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs?topic=menus#copy", + position: { x: 12, y: 24 }, + ...harness, + }); + + expect(harness.showContextMenu).toHaveBeenCalledWith( + [ + { id: "open-in-preview", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + { id: "copy-link", label: "Copy Link" }, + ], + { x: 12, y: 24 }, + ); + expect(harness.openInPreview).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it("copies the exact destination without opening it", async () => { + const harness = createHarness("copy-link"); + const href = "https://example.com/docs?topic=menus#copy"; + + await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); + + expect(harness.copyLink).toHaveBeenCalledWith(href); + expect(harness.openInPreview).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + }); + + it.each([ + ["open-in-preview" as const, "openInPreview" as const], + ["open-external" as const, "openExternal" as const], + ])("preserves the %s action", async (selection, expectedCallback) => { + const harness = createHarness(selection); + const href = "https://example.com/docs"; + + await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); + + expect(harness[expectedCallback]).toHaveBeenCalledWith(href); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it("reports the selected action when it fails", async () => { + const harness = createHarness("copy-link"); + const cause = new Error("clipboard denied"); + harness.copyLink.mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith("copy-link", cause); + }); + + it("reports the menu operation when the native menu cannot be shown", async () => { + const harness = createHarness(null); + const cause = new Error("menu unavailable"); + harness.showContextMenu.mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith("show-link-context-menu", cause); + expect(harness.openInPreview).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it.each([ + ["open-in-preview" as const, "openInPreview" as const, "open-link-in-preview"], + ["open-external" as const, "openExternal" as const, "open-link-external"], + ])("reports a failed %s action", async (selection, callback, operation) => { + const harness = createHarness(selection); + const cause = new Error("open failed"); + harness[callback].mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith(operation, cause); + }); + + it.each([ + ["https://example.com", "example.com"], + ["http://localhost:3000/path", "localhost"], + ["#details", null], + ["mailto:hello@example.com", null], + ["file:///tmp/example.txt", null], + ["javascript:void(0)", null], + ["not a URL", null], + [undefined, null], + ])("resolves the external web-link host for %s as %s", (href, expected) => { + expect(resolveExternalWebLinkHost(href)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts new file mode 100644 index 00000000000..398ca40da51 --- /dev/null +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -0,0 +1,78 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; + +export type ExternalLinkContextMenuAction = "open-in-preview" | "open-external" | "copy-link"; + +export type ExternalLinkContextMenuFailureOperation = + | "show-link-context-menu" + | "open-link-in-preview" + | "open-link-external" + | "copy-link"; + +const FAILURE_OPERATION_BY_ACTION = { + "open-in-preview": "open-link-in-preview", + "open-external": "open-link-external", + "copy-link": "copy-link", +} as const satisfies Record; + +const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [ + { id: "open-in-preview", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + { id: "copy-link", label: "Copy Link" }, +] as const satisfies readonly ContextMenuItem[]; + +interface ShowExternalLinkContextMenuOptions { + readonly href: string; + readonly position: { readonly x: number; readonly y: number }; + readonly showContextMenu: ( + items: readonly ContextMenuItem[], + position: { readonly x: number; readonly y: number }, + ) => Promise; + readonly openInPreview: (href: string) => Promise; + readonly openExternal: (href: string) => Promise; + readonly copyLink: (href: string) => Promise; + readonly reportFailure: ( + operation: ExternalLinkContextMenuFailureOperation, + cause: unknown, + ) => void; +} + +export function resolveExternalWebLinkHost(href: string | undefined): string | null { + if (!href) return null; + try { + const url = new URL(href); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.hostname || null; + } catch { + return null; + } +} + +export async function showExternalLinkContextMenu({ + href, + position, + showContextMenu, + openInPreview, + openExternal, + copyLink, + reportFailure, +}: ShowExternalLinkContextMenuOptions): Promise { + let action: ExternalLinkContextMenuAction | null; + try { + action = await showContextMenu(EXTERNAL_LINK_CONTEXT_MENU_ITEMS, position); + } catch (cause) { + reportFailure("show-link-context-menu", cause); + return; + } + + try { + if (action === "open-in-preview") { + await openInPreview(href); + } else if (action === "open-external") { + await openExternal(href); + } else if (action === "copy-link") { + await copyLink(href); + } + } catch (cause) { + if (action) reportFailure(FAILURE_OPERATION_BY_ACTION[action], cause); + } +} From f74eb62661734927aea45a243e2c3effced43fb9 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:53:07 +0530 Subject: [PATCH 046/110] fix(desktop): handle EPIPE errors on stdout/stderr to prevent crash dialog (#4213) --- apps/desktop/src/main.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7a51700e0fd..9795f04e8ae 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,9 @@ +for (const stream of [process.stdout, process.stderr]) { + stream.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "EPIPE") throw err; + }); +} + import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; From 18fa89c4adaae722e619f6abde093becf80efc08 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 17:26:41 +0200 Subject: [PATCH 047/110] Preserve draft thread highlighting during promotion (#4283) Co-authored-by: codex --- apps/web/src/components/Sidebar.tsx | 13 ++++++++++--- apps/web/src/threadRoutes.test.ts | 28 ++++++++++++++++++++++++++++ apps/web/src/threadRoutes.ts | 23 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf3fa30cf8d..a765fe64d92 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -118,7 +118,7 @@ import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildThreadRouteParams, - resolveThreadRouteRef, + resolveActiveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -3068,10 +3068,17 @@ export default function Sidebar() { const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); - const routeThreadRef = useParams({ + const routeTarget = useParams({ strict: false, - select: (params) => resolveThreadRouteRef(params), + select: (params) => resolveThreadRouteTarget(params), }); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index d15a233a304..644cdccb5c1 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -6,6 +6,7 @@ import { DraftId } from "./composerDraftStore"; import { buildDraftThreadRouteParams, buildThreadRouteParams, + resolveActiveThreadRouteRef, resolveThreadRouteRef, resolveThreadRouteTarget, } from "./threadRoutes"; @@ -64,4 +65,31 @@ describe("threadRoutes", () => { draftId: "draft-1", }); }); + + it("resolves the backing thread while a draft route is being promoted", () => { + const target = resolveThreadRouteTarget({ draftId: "draft-1" }); + + expect( + resolveActiveThreadRouteRef(target, { + environmentId: "env-1" as never, + threadId: ThreadId.make("draft-thread"), + promotedTo: scopeThreadRef("env-2" as never, ThreadId.make("server-thread")), + }), + ).toEqual({ + environmentId: "env-2", + threadId: "server-thread", + }); + }); + + it("does not treat a draft's reserved thread ref as an active sidebar thread", () => { + const target = resolveThreadRouteTarget({ draftId: "draft-1" }); + + expect( + resolveActiveThreadRouteRef(target, { + environmentId: "env-1" as never, + threadId: ThreadId.make("draft-thread"), + promotedTo: null, + }), + ).toBeNull(); + }); }); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 19a7d5ca603..a4d853c0a7f 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -12,6 +12,12 @@ export type ThreadRouteTarget = draftId: DraftId; }; +type DraftThreadRouteState = { + environmentId: EnvironmentId; + threadId: ThreadId; + promotedTo?: ScopedThreadRef | null; +}; + export function buildThreadRouteParams(ref: ScopedThreadRef): { environmentId: EnvironmentId; threadId: ThreadId; @@ -57,3 +63,20 @@ export function resolveThreadRouteTarget( draftId: params.draftId as DraftId, }; } + +/** + * Resolves the thread represented by either a canonical thread route or a + * draft route whose promotion to a server thread has been recorded. + */ +export function resolveActiveThreadRouteRef( + target: ThreadRouteTarget | null, + draftThread: DraftThreadRouteState | null, +): ScopedThreadRef | null { + if (target?.kind === "server") { + return target.threadRef; + } + if (target?.kind !== "draft" || !draftThread?.promotedTo) { + return null; + } + return draftThread.promotedTo; +} From 7e2bb475042ccfd196779831e69f5b19a38a2423 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 17:36:22 +0200 Subject: [PATCH 048/110] Move mobile working timer into the thread timeline (#4285) --- .../src/features/threads/ThreadComposer.tsx | 17 ++++- .../features/threads/ThreadDetailScreen.tsx | 69 ++----------------- .../src/features/threads/ThreadFeed.tsx | 45 +++++++++++- apps/mobile/src/lib/threadActivity.test.ts | 14 ++++ apps/mobile/src/lib/threadActivity.ts | 18 ++++- 5 files changed, 92 insertions(+), 71 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e712664d30a..c1a6f2c235e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -26,7 +26,13 @@ import { type ViewStyle, } from "react-native"; import ImageViewing from "react-native-image-viewing"; -import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; +import Animated, { + FadeIn, + FadeInDown, + FadeOut, + FadeOutDown, + LinearTransition, +} from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; @@ -232,7 +238,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( const isReconnecting = props.status.kind !== "unavailable"; return ( - + - + ); }); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 32527b0b719..5cb04290f66 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -14,21 +14,18 @@ import type { ServerConfig as T3ServerConfig, ThreadId, } from "@t3tools/contracts"; -import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Platform, View, type GestureResponderEvent } from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; -import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; +import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AppText as Text } from "../../components/AppText"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import type { PendingApproval, PendingUserInput, @@ -172,54 +169,6 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray Date.now()); - - useEffect(() => { - const intervalId = setInterval(() => { - setNowMs(Date.now()); - }, 1_000); - return () => clearInterval(intervalId); - }, [props.startedAt]); - - const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; - - return ( - - - - - - - - - - Working for {durationLabel} - - - - - ); -}); - export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const insets = useSafeAreaInsets(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; @@ -253,8 +202,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; - const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; - const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; + const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes // UIKit add the safe-area bottom to the content inset AGAIN — leaving a @@ -411,6 +359,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread contentPresentation={props.contentPresentation} agentLabel={agentLabel} latestTurn={props.selectedThread.latestTurn} + activeWorkStartedAt={props.activeWorkStartedAt} listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} @@ -438,15 +387,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} - - {props.activeWorkStartedAt ? ( - - ) : null} - + {props.activePendingApproval || props.activePendingUserInput ? ( ) : null} - + ; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; @@ -816,6 +818,10 @@ function renderFeedEntry( const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + if (entry.type === "working") { + return ; + } + if (entry.type === "turn-fold") { return ( Date.now()); + + useEffect(() => { + const intervalId = setInterval(() => { + setNowMs(Date.now()); + }, 1_000); + return () => clearInterval(intervalId); + }, [props.startedAt]); + + const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; + + return ( + + + + + + + + Working for {durationLabel} + + + ); +}); + function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; @@ -1415,8 +1447,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, expandedTurnIds, expandedWorkGroupIds, + props.activeWorkStartedAt, ), - [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], + [ + expandedTurnIds, + expandedWorkGroupIds, + props.activeWorkStartedAt, + props.feed, + props.latestTurn, + ], ); // The empty↔filled key below remounts the list, which resets its imperative @@ -1761,7 +1800,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }} /> - {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? ( + {props.feed.length === 0 && + props.activeWorkStartedAt === null && + props.contentPresentation.kind === "ready" ? ( { }); }); + it("appends active work as a normal timeline row", () => { + const startedAt = "2026-04-01T00:00:01.000Z"; + const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt); + + expect(presented).toEqual([ + { + type: "working", + id: "working-indicator-row", + createdAt: startedAt, + }, + ]); + expect(deriveThreadFeedPresentation(presented, null, new Set())).toEqual([]); + }); + it("models work-log overflow as list rows", () => { const activity = ( id: string, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9f79a90550d..6278247dc69 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -98,6 +98,11 @@ type RawThreadFeedEntry = export type ThreadFeedEntry = | Extract + | { + readonly type: "working"; + readonly id: string; + readonly createdAt: string; + } | { readonly type: "activity-group"; readonly id: string; @@ -1105,9 +1110,11 @@ export function deriveThreadFeedPresentation( latestTurn: ThreadFeedLatestTurn | null, expandedTurnIds: ReadonlySet, expandedWorkGroupIds: ReadonlySet = new Set(), + activeWorkStartedAt: string | null = null, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( - (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", + (entry) => + entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working", ); const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); const collapsedEntryIds = new Set(); @@ -1136,12 +1143,19 @@ export function deriveThreadFeedPresentation( appendPresentedFeedEntry(result, entry, expandedWorkGroupIds); } } + if (activeWorkStartedAt !== null) { + result.push({ + type: "working", + id: "working-indicator-row", + createdAt: activeWorkStartedAt, + }); + } return result; } function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude, expandedWorkGroupIds: ReadonlySet, ): void { if (entry.type !== "activity-group") { From 376c149eac201fd1492c7b7512a1de96c3b345b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 17:49:46 +0200 Subject: [PATCH 049/110] Stabilize PR status lookups and provider session lifecycle (#4281) Co-authored-by: Claude Fable 5 --- apps/server/src/git/GitManager.test.ts | 180 +++++++++++++++++- apps/server/src/git/GitManager.ts | 173 +++++++++++++++-- apps/server/src/server.test.ts | 3 + .../src/sourceControl/GitHubCli.test.ts | 55 ++++++ .../src/sourceControl/gitHubPullRequests.ts | 18 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 41 ++++ apps/server/src/vcs/GitVcsDriverCore.ts | 14 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 10 + apps/server/src/vcs/VcsStatusBroadcaster.ts | 7 +- 9 files changed, 465 insertions(+), 36 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..464c3afad64 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -50,6 +50,8 @@ interface FakeGhScenario { }; repositoryCloneUrls?: Record; failWith?: GitHubCli.GitHubCliError; + /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ + failAfterCalls?: number; } function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { @@ -382,7 +384,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const args = [...input.args]; ghCalls.push(args.join(" ")); - if (scenario.failWith) { + if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { return Effect.fail(scenario.failWith); } @@ -1336,6 +1338,182 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status keeps the last known PR when a later lookup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky"]); + + const existingPr = { + number: 214, + title: "Sticky PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/214", + baseRefName: "main", + headRefName: "feature/pr-sticky", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(214); + + // An explicit invalidation (user refresh, git action) bypasses the PR + // cache and forces a live lookup — which now fails. The badge must keep + // the last known PR instead of blanking out. + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(214); + }), + ); + + it.effect( + "status does not reuse a stale PR after the branch is retargeted to a different upstream", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-retarget"]); + + const originRemote = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originRemote]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-retarget"]); + + const existingPr = { + number: 214, + title: "Sticky PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/214", + baseRefName: "main", + headRefName: "feature/pr-retarget", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(214); + + // Retarget the branch to a different remote/upstream (e.g. the PR was + // reopened against a fork). The previously cached PR belonged to the + // old upstream and must not be shown against the new one. + const forkRemote = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork", forkRemote]); + yield* runGit(repoDir, ["push", "fork", "feature/pr-retarget"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to=fork/feature/pr-retarget", + "feature/pr-retarget", + ]); + + yield* manager.invalidateStatus(repoDir); + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr).toBeNull(); + }), + ); + + it.effect("status keeps the last known PR when the branch gains its first upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky-first-push"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + + const existingPr = { + number: 215, + title: "Sticky first-push PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/215", + baseRefName: "main", + headRefName: "feature/pr-sticky-first-push", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(215); + + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky-first-push"]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(215); + }), + ); + + it.effect("status drops the last known PR when the tracked remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-repointed"]); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-repointed"]); + + const existingPr = { + number: 216, + title: "Old remote PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/216", + baseRefName: "main", + headRefName: "feature/pr-repointed", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(216); + + const replacementRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "replacement", replacementRemoteDir]); + yield* runGit(repoDir, ["push", "replacement", "feature/pr-repointed"]); + yield* runGit(repoDir, ["remote", "set-url", "origin", replacementRemoteDir]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr).toBeNull(); + }), + ); + it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f1fb03e7e45..58bd8602df1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -32,6 +32,7 @@ import { import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, + normalizeGitRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, @@ -95,6 +96,9 @@ const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; +const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); +const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); +const PR_LOOKUP_CACHE_CAPACITY = 2_048; type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; @@ -142,6 +146,7 @@ interface BranchHeadContext { headSelectors: ReadonlyArray; preferredHeadSelector: string; remoteName: string | null; + headRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; @@ -768,6 +773,145 @@ export const make = Effect.gen(function* () { normalizeStatusCacheKey(cwd).pipe( Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), ); + // PR lookups hit the hosting provider's API (gh/glab/...), so they refresh + // on their own, slower cadence: ahead/behind counts stay fresh on every + // status poll while the PR association is re-fetched at most once per + // PR_LOOKUP_CACHE_TTL per branch. Git actions and user-driven refreshes bump + // the epoch (invalidateStatus) to bypass the cache immediately. + const prLookupEpochByCwd = new Map(); + const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0; + const bumpPrLookupEpoch = (cwd: string) => + normalizeStatusCacheKey(cwd).pipe( + Effect.map((cacheKey) => { + prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); + }), + ); + // Cache keys are NUL-joined [cwd, branch, upstreamRef, epoch] — none of the + // segments can contain a NUL byte, and refs are never empty, so "" decodes + // back to a null upstreamRef. + const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => + [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); + const prLookupCache = yield* Cache.makeWith( + (key: string) => { + const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); + const details = { + branch, + upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, + }; + return resolveBranchHeadContext(cwd, details).pipe( + Effect.flatMap((headContext) => + findLatestPrForHeadContext(cwd, headContext).pipe( + Effect.map((latest) => ({ latest, headContext })), + ), + ), + ); + }, + { + capacity: PR_LOOKUP_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_FAILURE_TTL), + }, + ); + // A transient lookup failure (rate limit, network blip) must not clear an + // already-known PR badge, so the last successful answer per branch sticks + // around as the fallback. Keep the resolved head context with it so a + // branch retargeted to another remote/fork cannot inherit the old badge. + interface LastKnownPr { + readonly pr: ReturnType | null; + readonly upstreamRef: string | null; + readonly headBranch: string; + readonly remoteName: string | null; + readonly headRemoteUrlKey: string | null; + } + const lastKnownPrByBranchKey = new Map(); + const rememberLastKnownPr = (branchKey: string, entry: LastKnownPr) => { + if ( + !lastKnownPrByBranchKey.has(branchKey) && + lastKnownPrByBranchKey.size >= PR_LOOKUP_CACHE_CAPACITY + ) { + const oldestKey = lastKnownPrByBranchKey.keys().next().value; + if (oldestKey !== undefined) { + lastKnownPrByBranchKey.delete(oldestKey); + } + } + lastKnownPrByBranchKey.set(branchKey, entry); + }; + const resolveLastKnownPr = ( + branchKey: string, + current: Pick, + ): ReturnType | null => { + const lastKnown = lastKnownPrByBranchKey.get(branchKey); + if (!lastKnown) return null; + if (lastKnown.headBranch !== current.headBranch) { + return null; + } + + // The normalized URL catches both remote-alias changes and an existing + // alias being repointed. It also lets an upstream appear after `push -u` + // without invalidating the fallback when it still targets the same repo. + if (lastKnown.headRemoteUrlKey !== null || current.headRemoteUrlKey !== null) { + return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null; + } + + // If neither remote URL is available, fall back to the remote identity + // encoded by tracked branches. A null-to-non-null transition is allowed + // because that is the expected first-push case. + if (lastKnown.upstreamRef !== null && current.upstreamRef !== null) { + return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null; + } + return lastKnown.pr; + }; + const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( + cwd: string, + details: { branch: string; upstreamRef: string | null; isDefaultBranch: boolean }, + ) { + // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first + // `push -u`) must not orphan the fallback value for the same branch. + const branchKey = `${cwd}\u0000${details.branch}`; + return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( + Effect.map(({ latest, headContext }) => { + if (!latest) return { pr: null, headContext }; + // On the default branch, only surface open PRs. + // Merged/closed matches are usually reverse-merge history, not the thread's PR context. + if (details.isDefaultBranch && latest.state !== "open") { + return { pr: null, headContext }; + } + return { pr: toStatusPr(latest), headContext }; + }), + Effect.tap(({ pr, headContext }) => + Effect.sync(() => + rememberLastKnownPr(branchKey, { + pr, + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }), + ), + ), + Effect.map(({ pr }) => pr), + Effect.catch((error) => + Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe( + Effect.annotateLogs({ + operation: "lookupStatusPr", + branch: details.branch, + errorTag: + typeof error === "object" && error !== null && "_tag" in error + ? String(error._tag) + : typeof error, + }), + Effect.andThen(resolveBranchHeadContext(cwd, details)), + Effect.map((headContext) => + resolveLastKnownPr(branchKey, { + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }), + ), + ), + ), + ); + }); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, options?: GitVcsDriver.GitRemoteStatusOptions, @@ -781,19 +925,11 @@ export const make = Effect.gen(function* () { const pr = details.branch !== null - ? yield* findLatestPr(cwd, { + ? yield* lookupStatusPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, - }).pipe( - Effect.map((latest) => { - if (!latest) return null; - // On the default branch, only surface open PRs. - // Merged/closed matches are usually reverse-merge history, not the thread's PR context. - if (details.isDefaultBranch && latest.state !== "open") return null; - return toStatusPr(latest); - }), - Effect.orElseSucceed(() => null), - ) + isDefaultBranch: details.isDefaultBranch, + }) : null; return { @@ -837,6 +973,7 @@ export const make = Effect.gen(function* () { ) { if (!remoteName) { return { + remoteUrlKey: null, repositoryNameWithOwner: null, ownerLogin: null, }; @@ -845,6 +982,7 @@ export const make = Effect.gen(function* () { const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); return { + remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, repositoryNameWithOwner, ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), }; @@ -915,6 +1053,9 @@ export const make = Effect.gen(function* () { preferredHeadSelector: ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, remoteName, + headRemoteUrlKey: + remoteRepository.remoteUrlKey ?? + (remoteName === null ? originRepository.remoteUrlKey : null), headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, @@ -960,11 +1101,10 @@ export const make = Effect.gen(function* () { return null; }); - const findLatestPr = Effect.fn("findLatestPr")(function* ( + const findLatestPrForHeadContext = Effect.fn("findLatestPrForHeadContext")(function* ( cwd: string, - details: { branch: string; upstreamRef: string | null }, + headContext: BranchHeadContext, ) { - const headContext = yield* resolveBranchHeadContext(cwd, details); const parsedByNumber = new Map(); for (const headSelector of headContext.headSelectors) { @@ -991,7 +1131,6 @@ export const make = Effect.gen(function* () { } return parsed[0] ?? null; }); - const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( cwd: string, result: Pick, @@ -1442,6 +1581,10 @@ export const make = Effect.gen(function* () { function* (cwd) { yield* invalidateLocalStatusResultCache(cwd); yield* invalidateRemoteStatusResultCache(cwd); + // Full invalidation is the explicit-freshness path (git actions, user + // refresh); it also bypasses the slow PR-lookup cache. The periodic + // status poll only invalidates local/remote and keeps the PR cache warm. + yield* bumpPrLookupEpoch(cwd); }, ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8a5e0b713e6..c8c4ff377e8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5285,6 +5285,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, localStatus: () => Effect.succeed({ isRepo: true, @@ -5331,6 +5332,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, localStatus: () => Effect.succeed({ isRepo: true, @@ -5407,6 +5409,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, localStatus: () => Deferred.succeed(localRefreshStarted, undefined).pipe( Effect.ignore, diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..5daf7676d60 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -208,6 +208,61 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("keeps pull requests from gh versions without headRepository.nameWithOwner", () => + // gh < 2.47 (e.g. Ubuntu-packaged 2.46) exports headRepository as + // {id, name} only. These entries must decode instead of being dropped, + // with nameWithOwner rebuilt from the owner login. + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 2829, + title: "Codex turn mapping", + url: "https://github.com/pingdotgg/codething-mvp/pull/2829", + baseRefName: "main", + headRefName: "t3code/codex-turn-mapping", + state: "OPEN", + mergedAt: null, + isCrossRepository: false, + headRepository: { + id: "R_kgDORLtfbQ", + name: "codething-mvp", + }, + headRepositoryOwner: { + id: "MDEyOk9yZ2FuaXphdGlvbjg5MTkxNzI3", + login: "pingdotgg", + }, + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "t3code/codex-turn-mapping", + }); + + assert.deepStrictEqual(result, [ + { + number: 2829, + title: "Codex turn mapping", + url: "https://github.com/pingdotgg/codething-mvp/pull/2829", + baseRefName: "main", + headRefName: "t3code/codex-turn-mapping", + state: "open", + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + ]); + }).pipe(Effect.provide(layer)), + ); + it.effect("reads repository clone URLs", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index d9dcb7f9ad1..ded3c0a90b0 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -30,17 +30,21 @@ const GitHubPullRequestSchema = Schema.Struct({ mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), + // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was + // added later. Both fields stay optional so a version-drifted gh CLI can + // never fail the decode and silently drop the PR from the list. headRepository: Schema.optional( Schema.NullOr( Schema.Struct({ - nameWithOwner: Schema.String, + nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), headRepositoryOwner: Schema.optional( Schema.NullOr( Schema.Struct({ - login: Schema.String, + login: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), @@ -71,11 +75,15 @@ function normalizeGitHubPullRequestState(input: { function normalizeGitHubPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedGitHubPullRequestRecord { - const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const explicitNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const headRepositoryName = trimOptionalString(raw.headRepository?.name); const headRepositoryOwnerLogin = trimOptionalString(raw.headRepositoryOwner?.login) ?? - (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") - ? (headRepositoryNameWithOwner.split("/")[0] ?? null) + (explicitNameWithOwner?.includes("/") ? (explicitNameWithOwner.split("/")[0] ?? null) : null); + const headRepositoryNameWithOwner = + explicitNameWithOwner ?? + (headRepositoryOwnerLogin && headRepositoryName + ? `${headRepositoryOwnerLogin}/${headRepositoryName}` : null); return { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9ffd3ed696d..ecd2d0ad2b0 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -690,6 +690,47 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }); + describe("remote operations", () => { + it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); + + const reusedForSsh = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "git@github.com:pingdotgg/t3code.git", + }); + assert.equal(reusedForSsh, "origin"); + + const reusedForSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com/pingdotgg/t3code", + }); + assert.equal(reusedForSshScheme, "origin"); + + const reusedForSshWithPort = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com:22/pingdotgg/t3code.git", + }); + assert.equal(reusedForSshWithPort, "origin"); + + const addedForFork = yield* driver.ensureRemote({ + cwd, + preferredName: "octocat", + url: "git@github.com:octocat/t3code.git", + }); + assert.equal(addedForFork, "octocat"); + assert.equal(yield* git(cwd, ["remote"]), "octocat\norigin"); + }), + ); + }); + describe("commit context", () => { it.effect("stages selected files and commits only those files", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 471ec10b566..33eb6d40d76 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -25,7 +25,7 @@ import { type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; +import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; @@ -235,14 +235,6 @@ function sanitizeRemoteName(value: string): string { return sanitized.length > 0 ? sanitized : "fork"; } -function normalizeRemoteUrl(value: string): string { - return value - .trim() - .replace(/\/+$/g, "") - .replace(/\.git$/i, "") - .toLowerCase(); -} - function parseRemoteFetchUrls(stdout: string): Map { const remotes = new Map(); for (const line of stdout.split("\n")) { @@ -1092,7 +1084,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "ensureRemote", )(function* (input) { const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const normalizedTargetUrl = normalizeGitRemoteUrl(input.url); const remoteFetchUrls = yield* runGitStdout( "GitVcsDriver.ensureRemote.listRemoteUrls", input.cwd, @@ -1100,7 +1092,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { return remoteName; } } diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 032e48e4612..ee595b1f836 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -94,6 +94,11 @@ function makeTestLayer(state: { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), }), ), ); @@ -206,6 +211,11 @@ describe("VcsStatusBroadcaster", () => { Effect.sync(() => { state.remoteInvalidationCalls += 1; }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), }), ), ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index c238154f58c..b02ca9deb66 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -366,10 +366,9 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - yield* Effect.all([workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], { - concurrency: "unbounded", - discard: true, - }); + // invalidateStatus (not the two partial invalidations) so an explicit + // refresh also bypasses GitManager's slow PR-lookup cache. + yield* workflow.invalidateStatus(cwd); const [local, remote] = yield* Effect.all( [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], { concurrency: "unbounded" }, From 9fe4832a3f87e48152f915cfac9f85ec78e14e24 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:24:09 +0530 Subject: [PATCH 050/110] fix: open command palette instead of custom dialog for new thread picker in SidebarV2 (#4269) --- apps/web/src/commandPaletteBus.ts | 30 ++++ apps/web/src/commandPaletteContext.tsx | 29 ---- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/CommandPalette.tsx | 77 +++++++-- apps/web/src/components/Sidebar.tsx | 7 +- apps/web/src/components/SidebarV2.tsx | 156 ++---------------- .../src/components/chat/DraftHeroHeadline.tsx | 6 +- apps/web/src/newThreadPickerBus.ts | 14 -- apps/web/src/routes/_chat.index.tsx | 6 +- apps/web/src/routes/_chat.tsx | 8 +- 10 files changed, 118 insertions(+), 217 deletions(-) create mode 100644 apps/web/src/commandPaletteBus.ts delete mode 100644 apps/web/src/commandPaletteContext.tsx delete mode 100644 apps/web/src/newThreadPickerBus.ts diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts new file mode 100644 index 00000000000..2a953132992 --- /dev/null +++ b/apps/web/src/commandPaletteBus.ts @@ -0,0 +1,30 @@ +// Tiny event bus allowing components to programmatically open the command palette +// without owning its React state. +const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; + +export interface CommandPaletteOpenDetail { + readonly open?: "add-project" | "new-thread-in"; +} + +export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { + window.dispatchEvent( + new CustomEvent(COMMAND_PALETTE_OPEN_EVENT, detail ? { detail } : undefined), + ); +} + +export function onOpenCommandPalette( + listener: (detail: CommandPaletteOpenDetail) => void, +): () => void { + const handler = (event: Event) => { + listener((event as CustomEvent).detail ?? {}); + }; + window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); + return () => window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); +} + +/** Read at event time so consumers do not subscribe to transient dialog state. */ +export function isCommandPaletteOpen(): boolean { + return ( + typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null + ); +} diff --git a/apps/web/src/commandPaletteContext.tsx b/apps/web/src/commandPaletteContext.tsx deleted file mode 100644 index 8dae5fed3b5..00000000000 --- a/apps/web/src/commandPaletteContext.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { createContext, use, type ReactNode } from "react"; - -const OpenAddProjectCommandPaletteContext = createContext<(() => void) | null>(null); - -export function OpenAddProjectCommandPaletteProvider(props: { - readonly children: ReactNode; - readonly openAddProject: () => void; -}) { - return ( - - {props.children} - - ); -} - -export function useOpenAddProjectCommandPalette(): () => void { - const openAddProject = use(OpenAddProjectCommandPaletteContext); - if (!openAddProject) { - throw new Error("Command palette actions must be used inside CommandPalette"); - } - return openAddProject; -} - -/** Read at event time so the chat tree does not subscribe to transient dialog state. */ -export function isCommandPaletteOpen(): boolean { - return ( - typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null - ); -} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 04bd3a97c05..ad303f607e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -112,7 +112,7 @@ import { } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 9d824e0f72d..8c7e6d287ca 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -43,7 +43,7 @@ import { type ReactNode, } from "react"; import { useAtomValue } from "@effect/atom-react"; -import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; + import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -77,6 +77,7 @@ import { isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; +import { onOpenCommandPalette } from "../commandPaletteBus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; @@ -334,7 +335,7 @@ function errorMessage(error: unknown): string { } interface CommandPaletteOpenIntent { - readonly kind: "add-project"; + readonly kind: "add-project" | "new-thread-in"; } interface CommandPaletteUiState { @@ -346,6 +347,7 @@ type CommandPaletteUiAction = | { readonly _tag: "SetOpen"; readonly open: boolean } | { readonly _tag: "Toggle" } | { readonly _tag: "OpenAddProject" } + | { readonly _tag: "OpenNewThreadIn" } | { readonly _tag: "ClearOpenIntent" }; function reduceCommandPaletteUiState( @@ -362,6 +364,8 @@ function reduceCommandPaletteUiState( return { open: !state.open, openIntent: null }; case "OpenAddProject": return { open: true, openIntent: { kind: "add-project" } }; + case "OpenNewThreadIn": + return { open: true, openIntent: { kind: "new-thread-in" } }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; } @@ -375,6 +379,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); + const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const composerHandleRef = useRef(null); @@ -409,20 +414,32 @@ export function CommandPalette({ children }: { children: ReactNode }) { return () => window.removeEventListener("keydown", onKeyDown); }, [keybindings, terminalOpen, toggleOpen]); + useEffect( + () => + onOpenCommandPalette((detail) => { + if (detail.open === "new-thread-in") { + openNewThreadIn(); + } else if (detail.open === "add-project") { + openAddProject(); + } else { + setOpen(true); + } + }), + [openAddProject, openNewThreadIn, setOpen], + ); + return ( - - - - {children} - - - - + + + {children} + + + ); } @@ -960,6 +977,36 @@ function OpenCommandPaletteDialog(props: { openAddProjectFlow(); }, [clearOpenIntent, openAddProjectFlow, openIntent]); + useLayoutEffect(() => { + if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) { + return; + } + clearOpenIntent(); + setAddProjectCloneFlow(null); + setViewStack([]); + setQuery(""); + const currentPrefix = + currentProjectEnvironmentId && currentProjectId + ? `new-thread-in:${currentProjectEnvironmentId}:${currentProjectId}` + : null; + const prioritized = currentPrefix + ? [ + ...projectThreadItems.filter((item) => item.value === currentPrefix), + ...projectThreadItems.filter((item) => item.value !== currentPrefix), + ] + : projectThreadItems; + pushPaletteView({ + addonIcon: , + groups: [{ value: "projects", label: "Projects", items: prioritized }], + }); + }, [ + clearOpenIntent, + currentProjectEnvironmentId, + currentProjectId, + openIntent, + projectThreadItems, + ]); + const actionItems: Array = []; if (projects.length > 0) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a765fe64d92..d3e3f836c00 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -177,7 +177,7 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, @@ -3086,7 +3086,10 @@ export default function Sidebar() { : false, ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); + const openAddProjectCommandPalette = useCallback( + () => openCommandPalette({ open: "add-project" }), + [], + ); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 352a2fc471e..32b3ae5e1ca 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -57,14 +57,8 @@ import { useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; -import { onOpenNewThreadPicker } from "../newThreadPickerBus"; -import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; -import { - resolveThreadActionProjectRef, - startNewThreadFromContext, - startNewThreadInProjectFromContext, -} from "../lib/chatThreadActions"; +import { openCommandPalette } from "../commandPaletteBus"; +import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; @@ -630,7 +624,10 @@ export default function SidebarV2() { reportFailure: false, }); const newThreadContext = useHandleNewThread(); - const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); + const openAddProjectCommandPalette = useCallback( + () => openCommandPalette({ open: "add-project" }), + [], + ); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); @@ -1301,12 +1298,8 @@ export default function SidebarV2() { // New thread defaults to the project you're in (active thread's project, // falling back to the top project) — same resolution the command palette - // uses. The chevron menu is the explicit project picker the flat list no - // longer gets from per-project headers. - const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false); - // chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in - // v2 with multiple projects it opens this picker via the event bus. - useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []); + // uses. The command palette already offers a "New thread in..." submenu + // for multi-project setups. const handleNewThreadClick = useCallback(() => { // One project: nothing to pick, create immediately. if (projects.length <= 1) { @@ -1319,57 +1312,9 @@ export default function SidebarV2() { }); return; } - setNewThreadPickerOpen(true); + if (isMobile) setOpenMobile(false); + openCommandPalette({ open: "new-thread-in" }); }, [isMobile, newThreadContext, projects.length, setOpenMobile]); - const createThreadInProject = useCallback( - (environmentId: (typeof projects)[number]["environmentId"], projectId: string) => { - setNewThreadPickerOpen(false); - if (isMobile) setOpenMobile(false); - const project = projects.find( - (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, - ); - if (!project) return; - void startNewThreadInProjectFromContext( - { - activeDraftThread: newThreadContext.activeDraftThread, - activeThread: newThreadContext.activeThread ?? undefined, - defaultProjectRef: newThreadContext.defaultProjectRef, - handleNewThread: newThreadContext.handleNewThread, - }, - scopeProjectRef(project.environmentId, project.id), - ); - }, - [isMobile, newThreadContext, projects, setOpenMobile], - ); - const contextualProjectRef = resolveThreadActionProjectRef({ - activeDraftThread: newThreadContext.activeDraftThread, - activeThread: newThreadContext.activeThread ?? undefined, - defaultProjectRef: newThreadContext.defaultProjectRef, - handleNewThread: newThreadContext.handleNewThread, - }); - const newThreadTargetRef = scopedProject - ? scopeProjectRef(scopedProject.environmentId, scopedProject.id) - : contextualProjectRef; - const newThreadTargetProject = newThreadTargetRef - ? (projects.find( - (project) => - project.environmentId === newThreadTargetRef.environmentId && - project.id === newThreadTargetRef.projectId, - ) ?? null) - : null; - // Picker order: the contextual default first (preselected), everything else - // after — the common case is Enter/click on the top row. - const newThreadPickerProjects = useMemo(() => { - if (!newThreadTargetProject) return projects; - return [ - newThreadTargetProject, - ...projects.filter( - (project) => - project.environmentId !== newThreadTargetProject.environmentId || - project.id !== newThreadTargetProject.id, - ), - ]; - }, [newThreadTargetProject, projects]); const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to @@ -1639,87 +1584,6 @@ export default function SidebarV2() { - - - - New thread in… - -
      { - if ( - event.key !== "ArrowDown" && - event.key !== "ArrowUp" && - event.key !== "Home" && - event.key !== "End" - ) { - return; - } - const container = event.currentTarget; - const options = [...container.querySelectorAll("button")]; - if (options.length === 0) return; - const currentIndex = options.findIndex((option) => option === document.activeElement); - const nextIndex = - event.key === "Home" - ? 0 - : event.key === "End" - ? options.length - 1 - : event.key === "ArrowDown" - ? (currentIndex + 1) % options.length - : (currentIndex - 1 + options.length) % options.length; - event.preventDefault(); - options[nextIndex]?.focus(); - }} - > - {newThreadPickerProjects.map((project, index) => { - const isDefault = index === 0 && newThreadTargetProject !== null; - return ( - - ); - })} - -
      -
      -
      ); } diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 6a88fb8da19..3f50d3818b5 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,9 +1,9 @@ import type { ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { FolderPlusIcon } from "lucide-react"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; -import { useOpenAddProjectCommandPalette } from "~/commandPaletteContext"; +import { openCommandPalette } from "~/commandPaletteBus"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useProjects, useThreadShells } from "~/state/entities"; import { sortScopedProjectsForSidebar } from "../Sidebar.logic"; @@ -29,7 +29,7 @@ export function DraftHeroHeadline({ const projects = useProjects(); const threads = useThreadShells(); const handleNewThread = useNewThreadHandler(); - const openAddProject = useOpenAddProjectCommandPalette(); + const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const orderedProjects = useMemo( () => sortScopedProjectsForSidebar(projects, threads, "updated_at"), diff --git a/apps/web/src/newThreadPickerBus.ts b/apps/web/src/newThreadPickerBus.ts deleted file mode 100644 index a1cc7560e20..00000000000 --- a/apps/web/src/newThreadPickerBus.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Tiny event bus connecting the global chat.new shortcut (handled in the -// _chat route layout) to SidebarV2's project picker dialog. The route layer -// can't render the picker itself — it lives with the sidebar — and the -// sidebar can't own the window keydown handler without racing the layout's. -const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker"; - -export function openNewThreadPicker(): void { - window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT)); -} - -export function onOpenNewThreadPicker(listener: () => void): () => void { - window.addEventListener(NEW_THREAD_PICKER_EVENT, listener); - return () => window.removeEventListener(NEW_THREAD_PICKER_EVENT, listener); -} diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 8aadbf9ea20..6e8dbe33ff5 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,9 +1,9 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { createFileRoute, Link } from "@tanstack/react-router"; import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { openCommandPalette } from "../commandPaletteBus"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; @@ -105,7 +105,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) { } function NoProjectsHero() { - const openAddProject = useOpenAddProjectCommandPalette(); + const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); return ( diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index b590c59ed9d..57824fe5188 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -2,9 +2,9 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; -import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { isCommandPaletteOpen } from "../commandPaletteBus"; import { useClientSettings } from "../hooks/useSettings"; -import { openNewThreadPicker } from "../newThreadPickerBus"; +import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -80,11 +80,11 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - // Sidebar v2 routes creation through its project picker whenever + // Sidebar v2 routes creation through the command palette whenever // there is a real choice to make; v1 (and single-project setups) // keep the immediate contextual create. if (sidebarV2Enabled && projectCount > 1) { - openNewThreadPicker(); + openCommandPalette({ open: "new-thread-in" }); return; } void startNewThreadFromContext({ From 9a0a07167f0623c3a7db0ffeff2e3939760309df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 18:11:07 +0200 Subject: [PATCH 051/110] fix(server): don't drop sticky PR fallback when remote URL can't be resolved (#4289) Co-authored-by: Claude Fable 5 --- apps/server/src/git/GitManager.test.ts | 48 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 23 ++++++++---- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 464c3afad64..000a9d99c6b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1514,6 +1514,54 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status keeps the last known PR when the current remote URL can't be resolved", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-config-hiccup"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-config-hiccup"]); + + const existingPr = { + number: 217, + title: "Config hiccup PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/217", + baseRefName: "main", + headRefName: "feature/pr-config-hiccup", + }; + const { manager } = yield* makeManager({ + ghScenario: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("rate limited"), + }), + failAfterCalls: 1, + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(217); + + // `remote.origin.url` reads go through readConfigValueNullable, which + // maps ANY failed read (a real "no remote configured" state or a + // transient git-config hiccup) to null the same way. Unsetting the + // key here reproduces that ambiguity without touching branch + // tracking (refs/remotes/origin/* and branch..remote are + // untouched) — the remote identity has not actually changed, so the + // sticky PR must survive even though the current lookup can no + // longer resolve a remote URL to compare against. + yield* runGit(repoDir, ["config", "--unset", "remote.origin.url"]); + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(217); + }), + ); + it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 58bd8602df1..e59613f7cc8 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -846,16 +846,25 @@ export const make = Effect.gen(function* () { } // The normalized URL catches both remote-alias changes and an existing - // alias being repointed. It also lets an upstream appear after `push -u` - // without invalidating the fallback when it still targets the same repo. - if (lastKnown.headRemoteUrlKey !== null || current.headRemoteUrlKey !== null) { + // alias being repointed. Both sides must be resolved before treating a + // mismatch as real: `readConfigValueNullable` swallows any git-config + // read failure into `null`, so a transient failure to resolve the + // *current* remote URL must read as "unknown", not as "no remote" — the + // latter would otherwise drop an already-known PR badge on every hiccup. + if (lastKnown.headRemoteUrlKey !== null && current.headRemoteUrlKey !== null) { return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null; } - // If neither remote URL is available, fall back to the remote identity - // encoded by tracked branches. A null-to-non-null transition is allowed - // because that is the expected first-push case. - if (lastKnown.upstreamRef !== null && current.upstreamRef !== null) { + // If the remote URL can't be compared, fall back to the remote identity + // encoded by tracked branches — same "both sides known" requirement, for + // the same reason. A null-to-non-null transition (upstream/remoteName) + // is allowed because that is the expected first-push case. + if ( + lastKnown.upstreamRef !== null && + current.upstreamRef !== null && + lastKnown.remoteName !== null && + current.remoteName !== null + ) { return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null; } return lastKnown.pr; From 78a0ea55c1d9edce8bcd2b3caff9510b4093e6d3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 22 Jul 2026 14:33:07 -0700 Subject: [PATCH 052/110] feat(web): copy branch name via right-click in the branch selector (#4275) Co-authored-by: Claude Fable 5 --- .../BranchToolbarBranchSelector.tsx | 69 ++++++++++++++++--- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 67ae3a8187d..cfd09272aae 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -3,7 +3,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; +import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { @@ -17,9 +17,12 @@ import { useRef, useState, useTransition, + type MouseEvent as ReactMouseEvent, } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; @@ -317,6 +320,45 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- // Branch actions // --------------------------------------------------------------------------- + const copyBranchName = useCallback((branchName: string) => { + void writeTextToClipboard(branchName, "branch name").then( + (didCopy) => { + if (!didCopy) return; + toastManager.add({ + type: "success", + title: "Branch name copied", + description: branchName, + }); + }, + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy branch name", + description: toBranchActionErrorMessage(error), + }), + ); + }, + ); + }, []); + + const handleBranchContextMenu = useCallback( + (event: ReactMouseEvent, branchName: string | null) => { + if (!branchName) return; + const api = readLocalApi(); + if (!api) return; + event.preventDefault(); + event.stopPropagation(); + const items: ContextMenuItem<"copy-branch-name">[] = [ + { id: "copy-branch-name", label: "Copy branch name", icon: "copy" }, + ]; + void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((action) => { + if (action === "copy-branch-name") copyBranchName(branchName); + }); + }, + [copyBranchName], + ); + const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action(); @@ -624,6 +666,7 @@ export function BranchToolbarBranchSelector({ value={itemValue} className="pe-1.5" onClick={() => selectBranch(refName)} + onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} >
      {itemValue} @@ -674,15 +717,23 @@ export function BranchToolbarBranchSelector({ {branchPrTooltip} ) : null} - } - className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" - disabled={isInitialBranchesLoadPending || isBranchActionPending} + {/* Context menu lives on the wrapper: the disabled Button has + pointer-events-none, so the trigger itself never sees right-clicks + while refs are loading or a branch action is pending. */} + handleBranchContextMenu(event, resolvedActiveBranch)} > - - {triggerLabel} - - + } + className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" + disabled={isInitialBranchesLoadPending || isBranchActionPending} + > + + {triggerLabel} + + +
      From ab4a88386d5227706fadc4feb045dfb3e7fe6e1d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 23:49:21 +0200 Subject: [PATCH 053/110] Add remote server updates and standalone service management (#4286) Co-authored-by: codex --- README.md | 2 + apps/server/src/bin.test.ts | 17 +- apps/server/src/bin.ts | 2 + apps/server/src/cli/connect.test.ts | 4 +- apps/server/src/cli/connect.ts | 80 +-- apps/server/src/cli/service.test.ts | 37 ++ apps/server/src/cli/service.ts | 199 ++++++ apps/server/src/cloud/bootService.test.ts | 37 +- apps/server/src/cloud/bootService.ts | 101 ++- apps/server/src/cloud/pinnedRuntime.test.ts | 129 ++++ apps/server/src/cloud/pinnedRuntime.ts | 176 ++++++ apps/server/src/cloud/selfUpdate.test.ts | 589 ++++++++++++++++++ apps/server/src/cloud/selfUpdate.ts | 428 +++++++++++++ .../src/environment/ServerEnvironment.ts | 5 + apps/server/src/server.ts | 7 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 14 + apps/server/src/ws.ts | 9 + apps/web/src/components/ChatView.tsx | 30 +- .../components/ServerUpdateAction.test.tsx | 198 ++++++ .../web/src/components/ServerUpdateAction.tsx | 201 ++++++ .../settings/ConnectionsSettings.tsx | 34 +- apps/web/src/versionSkew.test.ts | 32 + apps/web/src/versionSkew.ts | 32 +- docs/README.md | 12 +- docs/architecture/overview.md | 2 + docs/architecture/remote.md | 15 + docs/architecture/server-updates.md | 121 ++++ docs/cloud/t3-connect-clerk.md | 3 + docs/operations/release.md | 21 + docs/user/background-service.md | 42 ++ docs/user/remote-access.md | 13 + docs/user/server-updates.md | 56 ++ packages/client-runtime/src/state/server.ts | 6 + packages/contracts/src/environment.ts | 21 + packages/contracts/src/rpc.ts | 11 + packages/contracts/src/server.ts | 29 +- 36 files changed, 2566 insertions(+), 149 deletions(-) create mode 100644 apps/server/src/cli/service.test.ts create mode 100644 apps/server/src/cli/service.ts create mode 100644 apps/server/src/cloud/pinnedRuntime.test.ts create mode 100644 apps/server/src/cloud/pinnedRuntime.ts create mode 100644 apps/server/src/cloud/selfUpdate.test.ts create mode 100644 apps/server/src/cloud/selfUpdate.ts create mode 100644 apps/web/src/components/ServerUpdateAction.test.tsx create mode 100644 apps/web/src/components/ServerUpdateAction.tsx create mode 100644 docs/architecture/server-updates.md create mode 100644 docs/user/background-service.md create mode 100644 docs/user/server-updates.md diff --git a/README.md b/README.md index 6aebfc7e8b8..0fbbe90ee66 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ There's no public docs site yet, checkout the miscellaneous markdown files in [d ## Documentation - [Getting started](./docs/getting-started/quick-start.md) +- [Remote access](./docs/user/remote-access.md) +- [Keeping T3 Code in sync](./docs/user/server-updates.md) - [Architecture overview](./docs/architecture/overview.md) - [Provider guides](./docs/providers/codex.md) - [Operations](./docs/operations/ci.md) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 999547b71d8..91006a9bece 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -202,6 +202,18 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))), ); + it.effect("exposes service lifecycle commands without T3 Connect configuration", () => + Effect.gen(function* () { + const { output } = yield* captureStdout(runCli(["service", "--help"], noConnectCli)); + + assert.include(output, "Manage the T3 Code background service."); + assert.include(output, "install"); + assert.include(output, "uninstall"); + assert.include(output, "update"); + assert.include(output, "status"); + }), + ); + it.effect("reports fresh headless connect state without requiring local configuration", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync( @@ -305,7 +317,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { runConnectCli(["connect", "logout", "--base-dir", baseDir]), ); - assert.equal(output, "Signed out of T3 Connect locally."); + assert.equal( + output, + "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.", + ); assert.isFalse(NodeFS.existsSync(tokenPath)); }), ); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index ddfbf5e3ecc..a7767b50a15 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -13,6 +13,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; +import { serviceCommand } from "./cli/service.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -47,6 +48,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serveCommand, authCommand, projectCommand, + serviceCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index e63cbf331b5..cc52a50fe36 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -16,9 +16,9 @@ import { formatRelayClientReady, headlessSessionConfig, isPublishAgentActivityEnabledValue, - recoverBootServiceOffer, reportCloudDisconnectResults, } from "./connect.ts"; +import { recoverServiceOnboardingOffer } from "./service.ts"; it("explains how to complete headless authorization", () => { assert.equal( @@ -58,7 +58,7 @@ it.effect("detects headless operation from individual SSH config values", () => it.effect("treats cancelling optional background setup as a successful skip", () => Effect.gen(function* () { - const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({}))); + const result = yield* recoverServiceOnboardingOffer(Effect.fail(new Terminal.QuitError({}))); assert.isFalse(result); }), ); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 8b11fc73346..0369d47e4d7 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -6,7 +6,6 @@ import { } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; -import * as Terminal from "effect/Terminal"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; @@ -20,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; +import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import { FetchHttpClient, @@ -29,7 +29,6 @@ import { } from "effect/unstable/http"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; -import packageJson from "../../package.json" with { type: "json" }; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BootService from "../cloud/bootService.ts"; @@ -45,9 +44,13 @@ import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; -import * as ProcessRunner from "../processRunner.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { + bootServiceLayer, + offerServiceDuringOnboarding, + recoverServiceOnboardingOffer, +} from "./service.ts"; const jsonFlag = Flag.boolean("json").pipe( Flag.withDescription("Emit JSON instead of human-readable output."), @@ -396,21 +399,6 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { if (options.clearAuthorization) { const tokens = yield* CliTokenManager.CloudCliTokenManager; yield* tokens.clear; - - // uninstall itself no-ops when nothing is installed (and on non-Linux), - // so no status pre-check that could mask a real removal failure. - const bootService = yield* BootService.BootService; - yield* bootService.uninstall.pipe( - Effect.tap((removed) => - removed ? Console.log("Removed the T3 Code background service.") : Effect.void, - ), - Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)), - Effect.catch((error) => - Console.warn(`Could not remove the background service: ${error.message}`).pipe( - Effect.as(false), - ), - ), - ); } yield* reportCloudDisconnectResults({ @@ -420,7 +408,9 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { }); if (options.clearAuthorization) { - yield* Console.log("Signed out of T3 Connect locally."); + yield* Console.log( + "Signed out of T3 Connect locally.\nThe background service is managed separately with `t3 service`.", + ); } }); @@ -457,11 +447,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* ( - offer: Effect.Effect, -) => - offer.pipe( - Effect.catchTags({ - QuitError: () => Effect.succeed(false), - BootServiceUnsupportedError: (error) => - Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), - BootServiceCommandError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - BootServiceInstallError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), - }), - ); - export const connectCommand = Command.make("connect", { ...projectLocationFlags, headless: headlessFlag, @@ -748,7 +690,7 @@ export const connectCommand = Command.make("connect", { // Connect itself already succeeded; a boot-service failure must not // fail the command, just tell the user what happened and move on. - const background = yield* recoverBootServiceOffer(offerBootService); + const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); yield* Console.log( background ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out." diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts new file mode 100644 index 00000000000..e91e10000b6 --- /dev/null +++ b/apps/server/src/cli/service.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; + +import { formatServiceStatus } from "./service.ts"; + +const status = { + supported: true, + installed: true, + current: true, + unitPath: "/home/me/.config/systemd/user/t3code.service", + logPath: "/home/me/.t3/userdata/logs/boot-service.log", +} as const; + +it("reports the installed service version and host paths", () => { + assert.equal( + formatServiceStatus(status, "0.0.29"), + [ + "T3 Code service", + " Status: installed · t3@0.0.29", + " Unit: /home/me/.config/systemd/user/t3code.service", + " Logs: /home/me/.t3/userdata/logs/boot-service.log", + ].join("\n"), + ); +}); + +it("gives a direct repair command for a stale service", () => { + assert.include( + formatServiceStatus({ ...status, current: false }, "0.0.29"), + "Next: Run `npx t3@latest service update`.", + ); +}); + +it("explains service availability without systemd", () => { + assert.include( + formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), + "Supported on: Linux with systemd", + ); +}); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts new file mode 100644 index 00000000000..bd846eeee34 --- /dev/null +++ b/apps/server/src/cli/service.ts @@ -0,0 +1,199 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Terminal from "effect/Terminal"; +import { Command, GlobalFlag, Prompt } from "effect/unstable/cli"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import type * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) => + BootService.layer({ + baseDir: config.baseDir, + logsDir: config.logsDir, + cliVersion: packageJson.version, + }).pipe(Layer.provide(ProcessRunner.layer)); + +export type ServiceReconcileResult = + | { + readonly changed: false; + readonly status: BootService.BootServiceStatus; + } + | { + readonly changed: true; + readonly previouslyInstalled: boolean; + readonly plan: BootService.BootServicePlan; + }; + +/** Install, update, or repair the service using the CLI version running this command. */ +export const reconcileService = Effect.fn("cli.service.reconcile")(function* () { + const service = yield* BootService.BootService; + const status = yield* service.status; + if (status.installed && status.current) { + return { changed: false, status } satisfies ServiceReconcileResult; + } + const plan = yield* service.install; + return { + changed: true, + previouslyInstalled: status.installed, + plan, + } satisfies ServiceReconcileResult; +}); + +export function formatServiceStatus( + status: BootService.BootServiceStatus, + cliVersion: string, +): string { + if (!status.supported) { + return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd"; + } + if (!status.installed) { + return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; + } + return [ + "T3 Code service", + ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`, + ` Unit: ${status.unitPath}`, + ` Logs: ${status.logPath}`, + ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]), + ].join("\n"); +} + +const runServiceCommand = Effect.fn("cli.service.run")(function* ( + flags: { readonly baseDir: Parameters[0]["baseDir"] }, + run: Effect.Effect, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* run.pipe(Effect.provide(bootServiceLayer(config))); +}); + +const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe( + Command.withDescription("Install T3 Code as a background service for this user."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const result = yield* reconcileService(); + if (!result.changed) { + yield* Console.log( + `T3 Code service is already installed with t3@${packageJson.version}.`, + ); + return; + } + yield* Console.log( + `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, + ); + }), + ), + ), +); + +const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe( + Command.withDescription( + "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.", + ), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const result = yield* reconcileService(); + if (!result.changed) { + yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); + return; + } + yield* Console.log( + `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, + ); + }), + ), + ), +); + +const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( + Command.withDescription("Stop and remove the T3 Code background service."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + const removed = yield* service.uninstall; + yield* Console.log( + removed ? "Removed the T3 Code service." : "T3 Code service is not installed.", + ); + }), + ), + ), +); + +const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( + Command.withDescription("Show whether the T3 Code background service is installed."), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version)); + }), + ), + ), +); + +export const offerServiceDuringOnboarding = Effect.gen(function* () { + const service = yield* BootService.BootService; + const { supported, installed, current } = yield* service.status; + if (!supported) { + return false; + } + if (installed && current) { + yield* Console.log("T3 Code is already set up to run in the background on this machine."); + return true; + } + const wanted = yield* Prompt.run( + Prompt.confirm({ + message: installed + ? "The installed T3 Code service needs an update or repair. Update it now?" + : "Run T3 Code in the background whenever this machine boots? " + + "It stays reachable through T3 Connect even after you log out.", + initial: true, + }), + ); + if (!wanted) { + return false; + } + const result = yield* reconcileService(); + if (result.changed) { + yield* Console.log( + `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, + ); + } + return true; +}); + +export const recoverServiceOnboardingOffer = ( + offer: Effect.Effect, +) => + offer.pipe( + Effect.catchTags({ + QuitError: () => Effect.succeed(false), + BootServiceUnsupportedError: (error) => + Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), + BootServiceCommandError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + BootServiceInstallError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + }), + ); + +export const serviceCommand = Command.make("service").pipe( + Command.withDescription("Manage the T3 Code background service."), + Command.withSubcommands([ + serviceInstallCommand, + serviceUninstallCommand, + serviceUpdateCommand, + serviceStatusCommand, + ]), +); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index a24d54697d9..46e9a1da987 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -14,6 +14,7 @@ import { HostProcessPlatform, } from "@t3tools/shared/hostProcess"; +import { reconcileService } from "../cli/service.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as BootService from "./bootService.ts"; @@ -100,7 +101,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { unit, [ "[Unit]", - "Description=T3 Code server (T3 Connect)", + "Description=T3 Code server", "StartLimitIntervalSec=300", "StartLimitBurst=5", "", @@ -108,6 +109,7 @@ it("renders a systemd unit with absolute paths and append-mode logging", () => { "Type=simple", "WorkingDirectory=%h", "Environment=T3CODE_HOME=/home/theo/.t3", + "Environment=T3_BOOT_SERVICE_UNIT=t3code.service", "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", "Restart=always", "RestartSec=5", @@ -170,6 +172,33 @@ it("flags package-manager cache entry points as ephemeral", () => { }); it.layer(NodeServices.layer)("BootService", (it) => { + it.effect("reconciles the standalone service once and is then idempotent", () => + Effect.gen(function* () { + const { dirs } = yield* makeTestContext(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const first = yield* reconcileService().pipe( + Effect.provideService(BootService.BootService, service), + ); + assert.isTrue(first.changed); + if (!first.changed) return; + assert.isFalse(first.previouslyInstalled); + + const commandCount = commands.length; + const second = yield* reconcileService().pipe( + Effect.provideService(BootService.BootService, service), + ); + assert.isFalse(second.changed); + assert.lengthOf(commands, commandCount); + }), + ); + it.effect("installs the unit, enables the service, and enables linger", () => Effect.gen(function* () { const { dirs, fs, path } = yield* makeTestContext(); @@ -311,7 +340,7 @@ it.layer(NodeServices.layer)("BootService", (it) => { }), ); - it.effect("reports an installed-but-stale unit so connect can offer a repair", () => + it.effect("reports an installed-but-stale unit so the lifecycle can offer a repair", () => Effect.gen(function* () { const { dirs, fs, path } = yield* makeTestContext(); const commands: Array = []; @@ -402,8 +431,8 @@ it.layer(NodeServices.layer)("BootService", (it) => { const error = yield* service.install.pipe(Effect.flip); assert.isTrue(isCommandError(error)); - // A leftover unit would make the next connect report "already set up" - // even though linger never happened. + // A leftover unit would make status report "installed" even though + // linger never happened. assert.isFalse( yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0662b367049..d7e13e834f4 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -16,21 +16,19 @@ import { } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../processRunner.ts"; +import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts"; /** - * Installs T3 Code as a per-user boot service so a connected machine stays - * reachable through T3 Connect after the SSH session ends. Linux-only for - * now: systemd user unit + loginctl enable-linger. The service runs a pinned - * runtime installed under /runtime — never `npx t3`, whose cache is - * ephemeral and whose registry fetch at boot would make startup depend on - * the network. + * Installs T3 Code as a per-user boot service. Linux-only for now: systemd + * user unit + loginctl enable-linger. The service runs a stable or pinned + * runtime — never an ephemeral `npx t3` cache whose eviction could break + * startup. */ const BOOT_SERVICE_NAME = "t3code"; -const BOOT_RUNTIME_DIR = "runtime"; -const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; -const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; const EPHEMERAL_CACHE_SEGMENTS = [ "/_npx/", // npx @@ -92,7 +90,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { // relay connection, and Restart=always covers early-boot failures. return [ "[Unit]", - "Description=T3 Code server (T3 Connect)", + "Description=T3 Code server", // Give up after 5 crashes in 5 minutes so a persistently broken install // (deleted runtime, broken workspace) stops instead of restarting every // 5s forever and growing the unrotated append log without bound. @@ -103,6 +101,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, + `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, "Restart=always", "RestartSec=5", @@ -206,14 +205,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const unitDir = path.join(homeDir, ".config", "systemd", "user"); const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); const logPath = path.join(input.logsDir, "boot-service.log"); - const runtimeVersionDir = path.join( - input.baseDir, - BOOT_RUNTIME_DIR, - "versions", - input.cliVersion, - ); - const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs"); - const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete"); + const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); const requireSystemdLinux = Effect.gen(function* () { if (platform !== "linux" || homeDir === "") { @@ -263,52 +255,41 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (!isEphemeralCacheEntry(host.cliEntryPath)) { return; } - // The sentinel is written only after npm exits 0. Checking the entry - // file alone is not enough: npm extracts files before running native - // builds (node-pty), so a killed install leaves a plausible-looking but - // broken tree behind. - const alreadyPinned = yield* Effect.all([ - fs.exists(runtimeSentinelPath), - fs.exists(runtimeEntryPath), - ]).pipe( - Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - if (alreadyPinned) { - return; - } - yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe( - Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - yield* runStep( - "installing the pinned t3 runtime (this can take a few minutes)", - "npm", - [ - "install", - "--prefix", - runtimeVersionDir, - "--no-fund", - "--no-audit", - `t3@${input.cliVersion}`, - ], - // Native deps (node-pty) can compile from source on slow boxes; the - // ProcessRunner default of 60s would kill a healthy install. - { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT }, - ).pipe( - Effect.tapError(() => - fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore), + yield* ensurePinnedRuntimeInstalled({ + baseDir: input.baseDir, + version: input.cliVersion, + fs, + path, + runner, + }).pipe( + Effect.mapError((error) => + error.step.startsWith("installing") + ? new BootServiceCommandError({ + step: error.step, + exitCode: error.exitCode, + stdoutLength: error.stdoutLength, + stderrLength: error.stderrLength, + cause: error.cause, + }) + : new BootServiceInstallError({ cause: error }), + ), + Effect.tapError((error) => + DateTime.now.pipe( + Effect.flatMap((now) => + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { + flag: "a", + }), + ), + Effect.ignore, + ), ), ); - yield* fs - .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); }); // Where the unit will point: derivable without touching the network, so // status can compare units purely; install materializes it first. const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) - ? runtimeEntryPath + ? runtimePaths.entryPath : host.cliEntryPath; const plan: BootServicePlan = { nodePath: host.execPath, @@ -341,8 +322,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); // If any activation step fails, remove the unit again: a leftover file - // would make the next `t3 connect` report the service as already set up - // even though it was never enabled or lingered. + // would make service status report it as installed even though it was + // never enabled or lingered. yield* Effect.gen(function* () { yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); yield* runStep("enabling the service", "systemctl", [ @@ -372,7 +353,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // fails), leave nothing behind: disable removes the enable symlink, remove // deletes the file, daemon-reload clears the stale definition — otherwise a // dangling wants/ symlink logs "Failed to load unit" at every boot and the - // next connect misreports the state. + // next lifecycle command misreports the state. const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* ( previousUnit: Option.Option, ) { diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts new file mode 100644 index 00000000000..9b46c0038ec --- /dev/null +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -0,0 +1,129 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import { + ensurePinnedRuntimeInstalled, + pinnedRuntimePaths, + removePinnedRuntimeInstallation, +} from "./pinnedRuntime.ts"; + +it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { + it.effect("serializes concurrent installs of the same runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const installStarted = yield* Deferred.make(); + const allowInstallToFinish = yield* Deferred.make(); + const paths = pinnedRuntimePaths(path, baseDir, "0.0.29"); + let npmRuns = 0; + + const runner = ProcessRunner.ProcessRunner.of({ + run: (_input) => + Effect.gen(function* () { + npmRuns += 1; + yield* Deferred.succeed(installStarted, undefined); + yield* Deferred.await(allowInstallToFinish); + yield* fs + .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + const install = ensurePinnedRuntimeInstalled({ + baseDir, + version: "0.0.29", + fs, + path, + runner, + }); + + const first = yield* Effect.forkChild(install, { startImmediately: true }); + yield* Deferred.await(installStarted); + const second = yield* Effect.forkChild(install, { startImmediately: true }); + yield* Effect.yieldNow; + assert.equal(npmRuns, 1); + + yield* Deferred.succeed(allowInstallToFinish, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + assert.equal(npmRuns, 1); + assert.isTrue(yield* fs.exists(paths.sentinelPath)); + assert.isTrue(yield* fs.exists(paths.entryPath)); + }), + ); + + it.effect("waits for an active install before removing its runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const installStarted = yield* Deferred.make(); + const allowInstallToFinish = yield* Deferred.make(); + const paths = pinnedRuntimePaths(path, baseDir, "0.0.30"); + const runner = ProcessRunner.ProcessRunner.of({ + run: (_input) => + Effect.gen(function* () { + yield* Deferred.succeed(installStarted, undefined); + yield* Deferred.await(allowInstallToFinish); + yield* fs + .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + + const installFiber = yield* Effect.forkChild( + ensurePinnedRuntimeInstalled({ + baseDir, + version: "0.0.30", + fs, + path, + runner, + }), + { startImmediately: true }, + ); + yield* Deferred.await(installStarted); + const removeFiber = yield* Effect.forkChild( + removePinnedRuntimeInstallation({ + baseDir, + version: "0.0.30", + fs, + path, + }), + { startImmediately: true }, + ); + yield* Effect.yieldNow; + assert.isTrue(yield* fs.exists(paths.versionDir)); + + yield* Deferred.succeed(allowInstallToFinish, undefined); + yield* Fiber.join(installFiber); + yield* Fiber.join(removeFiber); + assert.isFalse(yield* fs.exists(paths.versionDir)); + }), + ); +}); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts new file mode 100644 index 00000000000..e3e095ce081 --- /dev/null +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -0,0 +1,176 @@ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * A pinned runtime is an exact `t3@` npm-installed into + * /runtime/versions/. The boot service points its systemd + * unit here, and server self-update installs the target version here before + * switching over — never `npx t3`, whose cache is ephemeral and whose + * registry fetch at boot would make startup depend on the network. + */ + +const PINNED_RUNTIME_DIR = "runtime"; +const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +// Boot-service setup and remote self-update share this module but can be +// constructed in separate layers. Serialize the complete check/install/ +// sentinel transaction across all callers in this process. +const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); + +export interface PinnedRuntimePaths { + readonly versionDir: string; + readonly entryPath: string; + readonly sentinelPath: string; +} + +export function pinnedRuntimePaths( + path: Path.Path, + baseDir: string, + version: string, +): PinnedRuntimePaths { + const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); + return { + versionDir, + entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + sentinelPath: path.join(versionDir, ".install-complete"), + }; +} + +export class PinnedRuntimeInstallError extends Schema.TaggedErrorClass()( + "PinnedRuntimeInstallError", + { + step: Schema.String, + exitCode: Schema.optional(Schema.Number), + stdoutLength: Schema.optional(Schema.Number), + stderrLength: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.exitCode === undefined + ? `Pinned runtime install failed while ${this.step}.` + : `Pinned runtime install failed while ${this.step} (exit code ${this.exitCode}).`; + } +} + +/** + * Installs `t3@` into the pinned runtime directory unless a complete + * install is already there, and returns its paths. The sentinel is written + * only after npm exits 0; checking the entry file alone is not enough — npm + * extracts files before running native builds (node-pty), so a killed + * install leaves a plausible-looking but broken tree behind. + */ +export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensure_installed")( + function* (input: { + readonly baseDir: string; + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly runner: ProcessRunner.ProcessRunner["Service"]; + }) { + const { fs, runner } = input; + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + + return yield* pinnedRuntimeInstallLock.withPermit( + Effect.gen(function* () { + const alreadyPinned = yield* Effect.all([ + fs.exists(paths.sentinelPath), + fs.exists(paths.entryPath), + ]).pipe( + Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }), + ), + ); + if (alreadyPinned) { + return paths; + } + + yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(paths.versionDir, { recursive: true })), + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "preparing the pinned runtime directory", + cause, + }), + ), + ); + + const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + yield* runner + .run({ + command: "npm", + args: [ + "install", + "--prefix", + paths.versionDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ], + // Native deps (node-pty) can compile from source on slow boxes; the + // ProcessRunner default of 60s would kill a healthy install. + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: installStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + Effect.tapError(() => + fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + + yield* fs + .writeFileString(paths.sentinelPath, `${input.version}\n`) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "recording the completed install", cause }), + ), + ); + + return paths; + }), + ); + }, +); + +/** Removes one pinned runtime while holding the same process-wide lock used + * by install/check/sentinel work, so cleanup cannot race another caller that + * is materializing or reusing the runtime tree. */ +export const removePinnedRuntimeInstallation = Effect.fn("cloud.pinned_runtime.remove")( + function* (input: { + readonly baseDir: string; + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + }) { + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + yield* pinnedRuntimeInstallLock.withPermit( + input.fs + .remove(paths.versionDir, { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "removing the pinned runtime", cause }), + ), + ), + ); + }, +); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts new file mode 100644 index 00000000000..28bcc0ffe62 --- /dev/null +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -0,0 +1,589 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { + BOOT_SERVICE_UNIT_ENV, + BOOT_SERVICE_UNIT_FILE, + renderBootServiceUnit, +} from "./bootService.ts"; +import * as SelfUpdate from "./selfUpdate.ts"; + +const NODE_PATH = "/usr/local/bin/node"; + +interface RecordedCommand { + readonly command: string; + readonly args: ReadonlyArray; +} + +const makeRecordingRunnerLayer = ( + commands: Array, + options?: { + readonly failWhen?: ((command: string, args: ReadonlyArray) => boolean) | undefined; + readonly stdoutFor?: + | ((command: string, args: ReadonlyArray) => string | undefined) + | undefined; + }, +) => + Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.sync(() => { + commands.push({ command: input.command, args: input.args }); + const failed = options?.failWhen?.(input.command, input.args) === true; + const versionFromPath = + input.command === NODE_PATH && input.args[1] === "--version" + ? /[/\\]runtime[/\\]versions[/\\]([^/\\]+)/.exec(input.args[0] ?? "")?.[1] + : undefined; + return { + stdout: + options?.stdoutFor?.(input.command, input.args) ?? + (versionFromPath === undefined ? "" : `${versionFromPath}\n`), + stderr: failed ? `${input.command} exploded` : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ); + +const provideHostRefs = (input: { + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; + readonly entryPath: string; +}) => + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed(HostProcessEnvironment, input.env), + Layer.succeed(HostProcessExecutablePath, NODE_PATH), + Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]), + ), + ); + +it("recognizes published npm artifacts as swappable entry points", () => { + assert.isTrue(SelfUpdate.isPublishedCliEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); + assert.isTrue( + SelfUpdate.isPublishedCliEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + ); + assert.isTrue( + SelfUpdate.isPublishedCliEntry( + "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", + ), + ); + // Dev checkouts and the desktop bundle run apps/server/dist directly. + assert.isFalse(SelfUpdate.isPublishedCliEntry("/home/theo/dev/t3/apps/server/dist/bin.mjs")); + assert.isFalse(SelfUpdate.isPublishedCliEntry("")); +}); + +it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => { + const makeHome = Effect.fn("test.makeHome")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + return { fs, path, home }; + }); + + const writeUnitReferencing = Effect.fn("test.writeUnitReferencing")(function* ( + home: string, + entryPath: string, + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const unitDir = path.join(home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + renderBootServiceUnit({ + nodePath: NODE_PATH, + t3EntryPath: entryPath, + baseDir: path.join(home, ".t3"), + logPath: path.join(home, ".t3", "userdata", "logs", "boot-service.log"), + unitPath: path.join(unitDir, "t3code.service"), + }), + ); + }); + + it.effect("reports boot-service for the systemd-spawned unit process", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + }, + entryPath, + }), + ); + assert.equal(method, "boot-service"); + }), + ); + + it.effect("does not claim a systemd process owned by another unit", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { HOME: home, INVOCATION_ID: "abc123" }, + entryPath, + }), + ); + assert.isNull(method); + }), + ); + + it.effect("reports respawn for a manual run of the pinned artifact", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + // Same unit on disk, but no INVOCATION_ID: restarting the unit would + // not replace this process, so it must respawn itself instead. + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe(provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath })); + assert.equal(method, "respawn"); + }), + ); + + it.effect("reports respawn for a foreground npx artifact on darwin", () => + Effect.gen(function* () { + const { home } = yield* makeHome(); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "darwin", + env: { HOME: home }, + entryPath: `${home}/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs`, + }), + ); + assert.equal(method, "respawn"); + }), + ); + + it.effect("reports desktop-managed for desktop-supervised backends", () => + Effect.gen(function* () { + const { home, path } = yield* makeHome(); + // Desktop ownership wins over every process-shape heuristic: even a + // systemd-looking pinned artifact belongs to the app that spawned it. + const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + yield* writeUnitReferencing(home, entryPath); + const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: true, + }).pipe( + provideHostRefs({ + platform: "linux", + env: { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + }, + entryPath, + }), + ); + assert.equal(method, "desktop-managed"); + }), + ); + + it.effect("reports no method for dev checkouts and Windows", () => + Effect.gen(function* () { + const { home } = yield* makeHome(); + const devMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "darwin", + env: { HOME: home }, + entryPath: `${home}/dev/t3/apps/server/dist/bin.mjs`, + }), + ); + assert.isNull(devMethod); + const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ + desktopManaged: false, + }).pipe( + provideHostRefs({ + platform: "win32", + env: { HOME: home }, + entryPath: "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", + }), + ); + assert.isNull(windowsMethod); + }), + ); +}); + +it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { + interface RecordedSpawn { + readonly command: string; + readonly args: ReadonlyArray; + } + + const makeContext = Effect.fn("test.makeContext")(function* (options?: { + readonly platform?: NodeJS.Platform; + readonly bootService?: boolean; + readonly desktopManaged?: boolean; + readonly entryPath?: string; + readonly failWhen?: (command: string, args: ReadonlyArray) => boolean; + readonly stdoutFor?: (command: string, args: ReadonlyArray) => string | undefined; + readonly failSpawn?: boolean; + }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + const baseDir = path.join(home, ".t3"); + const entryPath = + options?.entryPath ?? + path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); + const env: NodeJS.ProcessEnv = + options?.bootService === true + ? { + HOME: home, + INVOCATION_ID: "abc123", + [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, + } + : { HOME: home }; + if (options?.bootService === true) { + const unitDir = path.join(home, ".config", "systemd", "user"); + yield* fs.makeDirectory(unitDir, { recursive: true }); + yield* fs.writeFileString( + path.join(unitDir, "t3code.service"), + renderBootServiceUnit({ + nodePath: NODE_PATH, + t3EntryPath: entryPath, + baseDir, + logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"), + unitPath: path.join(unitDir, "t3code.service"), + }), + ); + } + + const commands: Array = []; + const spawns: Array = []; + let exited = 0; + // layerTest always reports mode "web"; desktop-managed contexts overlay + // the mode the desktop app's bootstrap envelope would set. + const configLayer = + options?.desktopManaged === true + ? Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return { ...config, mode: "desktop" as const }; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(home, baseDir))) + : ServerConfig.layerTest(home, baseDir); + const service = yield* SelfUpdate.make({ + host: { + spawnDetached: (command, args) => + Effect.sync(() => spawns.push({ command, args })).pipe( + Effect.andThen( + options?.failSpawn === true + ? Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command, + argumentCount: args.length, + cause: new Error("detached spawn failed"), + }), + ) + : Effect.void, + ), + ), + exitProcess: () => { + exited += 1; + }, + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeRecordingRunnerLayer(commands, { + failWhen: options?.failWhen, + stdoutFor: options?.stdoutFor, + }), + configLayer, + ), + ), + provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }), + ); + return { + fs, + path, + home, + baseDir, + entryPath, + commands, + spawns, + exitCount: () => exited, + service, + }; + }); + + it.effect("rejects dist-tags and other non-exact versions", () => + Effect.gen(function* () { + const context = yield* makeContext(); + const error = yield* context.service.update({ targetVersion: "latest" }).pipe(Effect.flip); + assert.include(error.reason, "not an exact t3 version"); + assert.lengthOf(context.commands, 0); + }), + ); + + it.effect("refuses to update a desktop-managed backend and points at the app", () => + Effect.gen(function* () { + const context = yield* makeContext({ desktopManaged: true, bootService: true }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "desktop app"); + assert.lengthOf(context.commands, 0); + assert.lengthOf(context.spawns, 0); + }), + ); + + it.effect("fails without touching anything when no update method applies", () => + Effect.gen(function* () { + const context = yield* makeContext({ + entryPath: "/home/theo/dev/t3/apps/server/dist/bin.mjs", + }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "cannot update itself"); + assert.lengthOf(context.commands, 0); + }), + ); + + it.effect("surfaces a failed npm install and never schedules a restart", () => + Effect.gen(function* () { + const context = yield* makeContext({ failWhen: (command) => command === "npm" }); + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.equal(error.reason, "Could not install the requested t3 version."); + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 0); + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("reinstalls the same version after a failed preflight", () => + Effect.gen(function* () { + let preflightAttempts = 0; + const context = yield* makeContext({ + failWhen: (command) => { + if (command !== NODE_PATH) return false; + preflightAttempts += 1; + return preflightAttempts === 1; + }, + }); + const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); + const entryPath = context.path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* context.fs.makeDirectory(context.path.dirname(entryPath), { recursive: true }); + yield* context.fs.writeFileString(entryPath, "export {};\n"); + yield* context.fs.writeFileString( + context.path.join(versionDir, ".install-complete"), + "0.0.29\n", + ); + + const firstError = yield* context.service + .update({ targetVersion: "0.0.29" }) + .pipe(Effect.flip); + assert.include(firstError.reason, "failed its version check"); + assert.isFalse(yield* context.fs.exists(versionDir)); + + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + assert.deepEqual( + context.commands.map((entry) => entry.command), + [NODE_PATH, "npm", NODE_PATH], + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("rejects and removes an installed runtime that reports the wrong version", () => + Effect.gen(function* () { + const context = yield* makeContext({ + stdoutFor: (command, args) => + command === NODE_PATH && args[1] === "--version" ? "0.0.28\n" : undefined, + }); + const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); + + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + + assert.include(error.reason, "did not report the requested"); + assert.isFalse(yield* context.fs.exists(versionDir)); + assert.lengthOf(context.spawns, 0); + }), + ); + + it.effect("reports a detached replacement spawn failure and leaves updates retryable", () => + Effect.gen(function* () { + const context = yield* makeContext({ failSpawn: true }); + + const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(first.reason, "Could not start the replacement"); + + const second = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(second.reason, "Could not start the replacement"); + assert.notInclude(second.reason, "already in progress"); + assert.lengthOf(context.spawns, 2); + assert.equal(context.exitCount(), 0); + }), + ); + + it.effect("installs, preflights, and respawns a foreground server", () => + Effect.gen(function* () { + const context = yield* makeContext(); + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); + assert.lengthOf(context.spawns, 1); + + const concurrentError = yield* context.service + .update({ targetVersion: "0.0.30" }) + .pipe(Effect.flip); + assert.include(concurrentError.reason, "already in progress"); + + const pinnedEntry = context.path.join( + context.baseDir, + "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", + ); + assert.deepEqual( + context.commands.map((entry) => [entry.command, ...entry.args].join(" ")), + [ + `npm install --prefix ${context.path.join(context.baseDir, "runtime/versions/0.0.29")} --no-fund --no-audit t3@0.0.29`, + `${NODE_PATH} ${pinnedEntry} --version`, + ], + ); + + // The restart is deferred so the RPC acknowledgement flushes first. + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 1); + const spawn = context.spawns[0]; + assert.equal(spawn?.command, "/bin/sh"); + assert.include(spawn?.args ?? [], pinnedEntry); + // The replacement replays the original CLI arguments. + assert.include(spawn?.args ?? [], "serve"); + assert.equal(context.exitCount(), 1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("rewrites the systemd unit and restarts the boot service", () => + Effect.gen(function* () { + const context = yield* makeContext({ bootService: true }); + const result = yield* context.service.update({ targetVersion: "0.0.29" }); + assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" }); + + const pinnedEntry = context.path.join( + context.baseDir, + "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", + ); + const unit = yield* context.fs.readFileString( + context.path.join(context.home, ".config", "systemd", "user", "t3code.service"), + ); + assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`); + assert.deepEqual( + context.commands.map((entry) => entry.command), + ["npm", NODE_PATH, "systemctl", "systemctl"], + ); + assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]); + + assert.deepEqual(context.commands[3], { + command: "systemctl", + args: ["--user", "restart", "t3code.service"], + }); + assert.lengthOf(context.spawns, 0); + // systemd replaces the process; the server must not exit itself. + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("restores the previous unit and permits a retry when systemd restart fails", () => + Effect.gen(function* () { + let failRestart = true; + const context = yield* makeContext({ + bootService: true, + failWhen: (command, args) => { + if (command !== "systemctl" || args[1] !== "restart" || !failRestart) { + return false; + } + failRestart = false; + return true; + }, + }); + const unitPath = context.path.join( + context.home, + ".config", + "systemd", + "user", + BOOT_SERVICE_UNIT_FILE, + ); + const previousUnit = yield* context.fs.readFileString(unitPath); + + const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(first.reason, "Restarting the systemd boot service failed"); + assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); + assert.deepEqual( + context.commands.slice(-2).map((entry) => entry.args), + [ + ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + ["--user", "daemon-reload"], + ], + ); + + const retry = yield* context.service.update({ targetVersion: "0.0.30" }); + assert.deepEqual(retry, { targetVersion: "0.0.30", method: "boot-service" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("restores the previous systemd unit when daemon-reload fails", () => + Effect.gen(function* () { + const context = yield* makeContext({ + bootService: true, + failWhen: (command) => command === "systemctl", + }); + const unitPath = context.path.join( + context.home, + ".config", + "systemd", + "user", + BOOT_SERVICE_UNIT_FILE, + ); + const previousUnit = yield* context.fs.readFileString(unitPath); + + const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); + assert.include(error.reason, "Reloading systemd units failed"); + assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); + assert.deepEqual( + context.commands.map((entry) => entry.command), + ["npm", NODE_PATH, "systemctl", "systemctl"], + ); + + yield* TestClock.adjust(Duration.seconds(10)); + assert.lengthOf(context.spawns, 0); + assert.equal(context.exitCount(), 0); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts new file mode 100644 index 00000000000..2df49910bd5 --- /dev/null +++ b/apps/server/src/cloud/selfUpdate.ts @@ -0,0 +1,428 @@ +// @effect-diagnostics nodeBuiltinImport:off +// node:child_process directly: the foreground-server replacement must be a +// detached fire-and-forget child that outlives this process, while Effect's +// ChildProcessSpawner ties every child to a scope that kills it. +import { + ServerSelfUpdateError, + type ServerSelfUpdateCapability, + type ServerSelfUpdateInput, + type ServerSelfUpdateResult, +} from "@t3tools/contracts"; +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as NodeChildProcess from "node:child_process"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import * as ServerConfig from "../config.ts"; +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { + BOOT_SERVICE_UNIT_ENV, + BOOT_SERVICE_UNIT_FILE, + quoteSystemdValue, + renderBootServiceUnit, +} from "./bootService.ts"; +import { ensurePinnedRuntimeInstalled, removePinnedRuntimeInstallation } from "./pinnedRuntime.ts"; + +/** + * Lets a connected client replace this server with another published `t3` + * version over RPC — the only update path that works when the user is not at + * the machine (phone against a home server, relay-managed box). The target + * version is npm-installed into the pinned runtime and verified before + * anything restarts, so a failed install leaves the running server untouched. + */ + +const PREFLIGHT_TIMEOUT = Duration.seconds(30); +/** Grace between acknowledging the RPC and killing the process, so the + response (and its relay hop) flushes before the socket drops. */ +const RESTART_DELAY = Duration.seconds(2); + +/** Exact npm versions only — never dist-tags — so the acknowledgement names + the version that was actually installed. Also keeps the value safe to + pass to npm and embed in filesystem paths. */ +const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +export interface ServerSelfUpdateHost { + readonly execPath: string; + readonly cliEntryPath: string; + /** Original CLI arguments after the entry path, replayed on respawn. */ + readonly cliArgs: ReadonlyArray; + /** Resolves once the foreground replacement process has actually spawned. */ + readonly spawnDetached: ( + command: string, + args: ReadonlyArray, + ) => Effect.Effect; + readonly exitProcess: () => void; +} + +function normalizeEntryPath(entryPath: string): string { + return entryPath.replaceAll("\\", "/"); +} + +/** + * Only a published npm artifact can be swapped for another version: dev + * checkouts (apps/server/dist) and the desktop app's bundled backend have no + * npm identity, and the desktop manages its own updates. + */ +export function isPublishedCliEntry(entryPath: string): boolean { + return normalizeEntryPath(entryPath).includes("/node_modules/t3/dist/"); +} + +/** + * The update path this process can offer, or null when only a manual + * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this + * backend and owns its version; only updating the app updates it. + * "boot-service" — this is the systemd-supervised process from + * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a + * foreground POSIX process running a published artifact: replace it with a + * detached child. Windows foreground runs are unsupported for now (no + * equivalent of the detach-and-exec handoff below). + */ +export const resolveServerSelfUpdateCapability = Effect.fn( + "cloud.server_self_update.resolve_capability", +)(function* (input: { + /** True when the desktop app supervises this backend (mode "desktop"). */ + readonly desktopManaged: boolean; +}) { + if (input.desktopManaged) { + return "desktop-managed" as const; + } + + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; + const hostArguments = yield* HostProcessArguments; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const entryPath = hostArguments[1] ?? ""; + if (entryPath === "") { + return null; + } + + const homeDir = env.HOME ?? ""; + if (platform === "linux" && homeDir !== "") { + const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe( + Effect.map((unit) => unit.includes(quoteSystemdValue(entryPath))), + Effect.orElseSucceed(() => false), + ); + // INVOCATION_ID only proves that some systemd unit launched us. The + // explicit marker written into t3code.service identifies this unit as the + // supervisor that will replace the current process when restarted. + if ( + unitReferencesEntry && + (env.INVOCATION_ID ?? "") !== "" && + env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_UNIT_FILE + ) { + return "boot-service" as const; + } + + // A process owned by another (or a legacy unmarked) systemd unit must not + // use the foreground respawn path: Restart=always could otherwise launch + // the old unit beside the detached replacement. + if ((env.INVOCATION_ID ?? "") !== "") { + return null; + } + } + + if ((platform === "linux" || platform === "darwin") && isPublishedCliEntry(entryPath)) { + return "respawn" as const; + } + + return null; +}); + +export class ServerSelfUpdate extends Context.Service< + ServerSelfUpdate, + { + readonly update: ( + input: ServerSelfUpdateInput, + ) => Effect.Effect; + } +>()("t3/cloud/selfUpdate/ServerSelfUpdate") {} + +export const make = Effect.fn("cloud.server_self_update.make")(function* (options?: { + readonly host?: Partial; +}) { + const serverConfig = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const env = yield* HostProcessEnvironment; + const hostExecPath = yield* HostProcessExecutablePath; + const hostArguments = yield* HostProcessArguments; + const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + }); + + const host: ServerSelfUpdateHost = { + execPath: options?.host?.execPath ?? hostExecPath, + cliEntryPath: options?.host?.cliEntryPath ?? hostArguments[1] ?? "", + cliArgs: options?.host?.cliArgs ?? hostArguments.slice(2), + spawnDetached: + options?.host?.spawnDetached ?? + ((command, args) => + Effect.callback((resume) => { + const spawnError = (cause: unknown) => + new ProcessRunner.ProcessSpawnError({ + command, + argumentCount: args.length, + cause, + }); + let child: NodeChildProcess.ChildProcess; + try { + child = NodeChildProcess.spawn(command, [...args], { + detached: true, + stdio: "ignore", + }); + } catch (cause) { + resume(Effect.fail(spawnError(cause))); + return; + } + + const onSpawnError = (cause: Error) => resume(Effect.fail(spawnError(cause))); + child.once("error", onSpawnError); + child.once("spawn", () => { + child.removeListener("error", onSpawnError); + // Keep asynchronous child errors from becoming uncaught after the + // successful spawn handoff has already been acknowledged. + child.on("error", () => undefined); + child.unref(); + resume(Effect.void); + }); + })), + exitProcess: options?.host?.exitProcess ?? (() => process.exit(0)), + }; + + const inFlight = yield* Ref.make(false); + + const failWith = (reason: string, cause?: unknown) => + cause === undefined + ? new ServerSelfUpdateError({ reason }) + : new ServerSelfUpdateError({ reason, cause }); + + /** Deferred so the RPC acknowledgement flushes before the process dies. + Detached from the request scope: the triggering connection is exactly + what the restart tears down. */ + const scheduleRestart = (restart: Effect.Effect) => + Effect.sleep(RESTART_DELAY).pipe( + Effect.andThen(restart), + Effect.forkDetach({ startImmediately: true }), + ); + const writeUnitAtomically = (filePath: string, contents: string) => + writeFileStringAtomically({ filePath, contents }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + + const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( + "cloud.server_self_update.update", + )(function* (input) { + if (capability === "desktop-managed") { + return yield* failWith( + "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", + ); + } + if (capability === null) { + return yield* failWith( + "This server cannot update itself; relaunch it manually with the new version.", + ); + } + const activeMethod = capability; + const targetVersion = input.targetVersion.trim(); + if (!EXACT_VERSION_PATTERN.test(targetVersion)) { + return yield* failWith(`'${targetVersion}' is not an exact t3 version.`); + } + + const alreadyRunning = yield* Ref.getAndSet(inFlight, true); + if (alreadyRunning) { + return yield* failWith("A server update is already in progress."); + } + + return yield* Effect.gen(function* () { + const runtimePaths = yield* ensurePinnedRuntimeInstalled({ + baseDir: serverConfig.baseDir, + version: targetVersion, + fs, + path, + runner, + }).pipe( + Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)), + ); + + // A broken artifact (failed native build, incompatible node) must be + // caught while the current server is still alive to report it. + const preflight = yield* runner + .run({ + command: host.execPath, + args: [runtimePaths.entryPath, "--version"], + timeout: PREFLIGHT_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => + failWith(`Could not verify the installed t3@${targetVersion}.`, cause), + ), + ); + const preflightVersion = preflight.stdout.trim(); + if (preflight.code !== 0 || preflightVersion !== targetVersion) { + // A completed npm install can still be unusable under this Node or on + // this machine. Remove its sentinel and tree so a retry of the same + // version performs a clean install instead of reusing a known-bad one. + yield* removePinnedRuntimeInstallation({ + baseDir: serverConfig.baseDir, + version: targetVersion, + fs, + path, + }).pipe( + Effect.mapError((error) => + failWith(`Could not remove the failed t3@${targetVersion} installation.`, error), + ), + ); + return yield* failWith( + preflight.code !== 0 + ? `The installed t3@${targetVersion} failed its version check (exit code ${String(preflight.code)}).` + : `The installed runtime did not report the requested t3@${targetVersion} version.`, + ); + } + + if (activeMethod === "boot-service") { + const homeDir = env.HOME ?? ""; + const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); + const previousUnit = yield* fs + .readFileString(unitPath) + .pipe( + Effect.mapError((cause) => failWith("Could not read the current systemd unit.", cause)), + ); + // Same shape bootService.install writes, so host lifecycle commands + // still recognize the unit as current. + const unit = renderBootServiceUnit({ + nodePath: host.execPath, + t3EntryPath: runtimePaths.entryPath, + baseDir: serverConfig.baseDir, + logPath: path.join(serverConfig.logsDir, "boot-service.log"), + unitPath, + }); + yield* writeUnitAtomically(unitPath, unit).pipe( + Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause)), + ); + + const reloadSystemd = Effect.fn("cloud.server_self_update.reload_systemd")(function* () { + const reload = yield* runner + .run({ command: "systemctl", args: ["--user", "daemon-reload"] }) + .pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause))); + if (reload.code !== 0) { + return yield* failWith( + `Reloading systemd units failed (exit code ${String(reload.code)}).`, + ); + } + }); + + yield* reloadSystemd().pipe( + Effect.catch((reloadError) => + writeUnitAtomically(unitPath, previousUnit).pipe( + Effect.mapError((rollbackCause) => + failWith("Could not restore the previous systemd unit.", { + reloadError, + rollbackCause, + }), + ), + // Systemd should still have the old unit in memory after the + // failed reload, but retry after restoring in case it applied a + // partial update before returning an error. + Effect.andThen(reloadSystemd().pipe(Effect.ignore)), + Effect.andThen(Effect.fail(reloadError)), + ), + ), + ); + yield* Effect.logInfo("Server self-update installed; restarting boot service.", { + targetVersion, + }); + // A successful systemd restart stops this process, so the RPC is + // interrupted and the reconnecting client observes the new version. + // A rejected restart returns while the old process is still alive; + // restore the previous unit and report that failure through the RPC. + yield* Effect.gen(function* () { + const restart = yield* runner + .run({ + command: "systemctl", + args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + }) + .pipe( + Effect.mapError((cause) => + failWith("Could not restart the systemd boot service.", cause), + ), + ); + if (restart.code !== 0) { + return yield* failWith( + `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`, + ); + } + }).pipe( + Effect.catch((restartError) => + writeUnitAtomically(unitPath, previousUnit).pipe( + Effect.andThen(reloadSystemd()), + Effect.mapError((rollbackError) => + failWith("Could not restore the previous systemd unit.", { + restartError, + rollbackError, + }), + ), + Effect.andThen(Effect.fail(restartError)), + ), + ), + ); + } else { + // Spawn the shim before acknowledging the RPC so ENOENT/EACCES and + // other launch failures leave this server alive and return a useful + // error. The shim itself waits until after the acknowledgement and + // deferred exit before binding the replacement server. + yield* host + .spawnDetached("/bin/sh", [ + "-c", + 'sleep 3; exec "$@"', + "t3-self-update", + host.execPath, + runtimePaths.entryPath, + ...host.cliArgs, + ]) + .pipe( + Effect.mapError((cause) => + failWith("Could not start the replacement t3 process.", cause), + ), + ); + yield* Effect.logInfo("Server self-update installed; respawning.", { targetVersion }); + yield* scheduleRestart( + Effect.try({ + try: () => host.exitProcess(), + catch: (cause) => failWith("Could not exit the replaced t3 process.", cause), + }).pipe( + Effect.catch((error) => + Effect.logError("Server self-update could not exit the replaced process.").pipe( + Effect.annotateLogs({ targetVersion, error: error.reason }), + Effect.ensuring(Ref.set(inFlight, false)), + ), + ), + ), + ); + } + + return { targetVersion, method: activeMethod }; + }).pipe(Effect.onError(() => Ref.set(inFlight, false))); + }); + + return ServerSelfUpdate.of({ update }); +}); + +export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 01567b98d32..181237d76b6 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -9,6 +9,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; +import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; @@ -124,6 +125,9 @@ export const make = Effect.gen(function* () { const environmentId = EnvironmentId.make(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + const serverSelfUpdate = yield* resolveServerSelfUpdateCapability({ + desktopManaged: serverConfig.mode === "desktop", + }); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -137,6 +141,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, threadSettlement: true, + ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0e6db87b109..21d0ebe5fff 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -79,6 +79,7 @@ import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -361,7 +362,11 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), -).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer)); +).pipe( + Layer.provide(PreviewAutomationBroker.layer), + Layer.provide(ServerSelfUpdate.layer), + Layer.provide(browserApiCorsLayer), +); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ecd2d0ad2b0..7b5956de07f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -713,6 +713,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); assert.equal(reusedForSshScheme, "origin"); + const reusedForBareSshScheme = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://github.com/pingdotgg/t3code", + }); + assert.equal(reusedForBareSshScheme, "origin"); + + const reusedForSshPort = yield* driver.ensureRemote({ + cwd, + preferredName: "pingdotgg", + url: "ssh://git@github.com:22/pingdotgg/t3code", + }); + assert.equal(reusedForSshPort, "origin"); + const reusedForSshWithPort = yield* driver.ensureRemote({ cwd, preferredName: "pingdotgg", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 08b1770a0a2..f6f46d1e76e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -78,6 +78,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; +import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -296,6 +297,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], + [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], @@ -418,6 +420,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -1476,6 +1479,10 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverUpdateServer]: (input) => + observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, @@ -2079,6 +2086,7 @@ const makeWsRpcLayer = ( export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; return HttpRouter.add( "GET", "/ws", @@ -2101,6 +2109,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ad303f607e7..1e5e1ee2a5c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -254,11 +254,14 @@ import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; +import { ServerUpdateAction } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, + serverUpdateGuidance, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; @@ -1784,6 +1787,9 @@ function ChatViewContent(props: ChatViewProps) { hasMultipleRegisteredEnvironments && activeThread ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` : "server"; + const versionMismatchEnvironmentId = + versionMismatch && activeThread ? activeThread.environmentId : null; + const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); const composerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { @@ -1822,7 +1828,12 @@ function ChatViewContent(props: ChatViewProps) { ), }); } - if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { + if ( + showVersionMismatchBanner && + versionMismatch && + versionMismatchDismissKey && + versionMismatchEnvironmentId + ) { items.push({ id: `version-mismatch:${versionMismatchDismissKey}`, variant: "warning", @@ -1831,9 +1842,21 @@ function ChatViewContent(props: ChatViewProps) { description: ( <> Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} - {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail. + {versionMismatch.serverVersion}.{" "} + {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)} ), + // The desktop-managed guidance is already the description; the action + // slot would only repeat it. + actions: + versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( + + ), dismissLabel: "Dismiss version mismatch warning", onDismiss: () => { dismissVersionMismatch(versionMismatchDismissKey); @@ -1846,9 +1869,12 @@ function ChatViewContent(props: ChatViewProps) { activeEnvironmentUnavailableState, handleReconnectActiveEnvironment, navigate, + setDismissedVersionMismatchKey, showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, + versionMismatchEnvironmentId, + versionMismatchSelfUpdate, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx new file mode 100644 index 00000000000..c0ba1c693d4 --- /dev/null +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -0,0 +1,198 @@ +import type { Dispatch, ReactElement, SetStateAction } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import type { EnvironmentId } from "@t3tools/contracts"; + +const testState = vi.hoisted(() => ({ + updateServer: vi.fn(), + toast: vi.fn(), +})); + +const hooks = vi.hoisted(() => { + let cursor = 0; + let slots: unknown[] = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + slots = []; + }, + useEffect() { + nextIndex(); + }, + useMemoCache(size: number): unknown[] { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); + } + return slots[index] as unknown[]; + }, + useRef(initialValue: T): { current: T } { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = { current: initialValue }; + } + return slots[index] as { current: T }; + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const index = nextIndex(); + if (index >= slots.length) { + slots[index] = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + const setValue: Dispatch> = (nextValue) => { + const previous = slots[index] as T; + slots[index] = + typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; + }; + return [slots[index] as T, setValue]; + }, + }; +}); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useEffect: hooks.useEffect, + useRef: hooks.useRef, + useState: hooks.useState, + }; +}); + +vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); +vi.mock("~/hooks/useCopyToClipboard", () => ({ + useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), +})); +vi.mock("~/state/server", () => ({ + serverEnvironment: { updateServer: Symbol("updateServer") }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: () => testState.updateServer, +})); +vi.mock("./ui/toast", () => ({ + toastManager: { add: testState.toast }, +})); + +import { ServerUpdateAction } from "./ServerUpdateAction"; + +type ActionElement = ReactElement<{ + readonly disabled?: boolean; + readonly onClick?: () => void; +}>; + +function renderAction(): ActionElement { + hooks.beginRender(); + return ServerUpdateAction({ + environmentId: "env-test" as EnvironmentId, + serverLabel: "Test server", + selfUpdate: "boot-service", + targetVersion: "0.0.29", + }) as ActionElement; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("ServerUpdateAction", () => { + beforeEach(() => { + vi.useFakeTimers(); + hooks.reset(); + testState.updateServer.mockReset(); + testState.toast.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts a fresh reconnect timeout after a long install succeeds", async () => { + const update = + deferred< + ReturnType> + >(); + testState.updateServer.mockReturnValue(update.promise); + + renderAction().props.onClick?.(); + expect(renderAction().props.disabled).toBe(true); + + await vi.advanceTimersByTimeAsync(11 * 60_000); + update.resolve( + AsyncResult.success({ + targetVersion: "0.0.29", + method: "boot-service", + }), + ); + await flushPromises(); + + // The click-based deadline would have fired by now. Success gets a fresh + // twelve-minute reconnect window, so the action remains disabled. + await vi.advanceTimersByTimeAsync(2 * 60_000); + expect(renderAction().props.disabled).toBe(true); + expect(testState.toast).not.toHaveBeenCalledWith( + expect.objectContaining({ title: "Server update timed out" }), + ); + + await vi.advanceTimersByTimeAsync(10 * 60_000); + expect(renderAction().props.disabled).not.toBe(true); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Server update timed out" }), + ); + }); + + it("does not let an expired request clear a newer retry", async () => { + const first = deferred>(); + const retry = + deferred< + ReturnType> + >(); + testState.updateServer.mockReturnValueOnce(first.promise).mockReturnValueOnce(retry.promise); + + renderAction().props.onClick?.(); + await vi.advanceTimersByTimeAsync(12 * 60_000); + expect(renderAction().props.disabled).not.toBe(true); + + renderAction().props.onClick?.(); + expect(renderAction().props.disabled).toBe(true); + + first.resolve(AsyncResult.failure(Cause.fail(new Error("first request failed late")))); + await flushPromises(); + + expect(renderAction().props.disabled).toBe(true); + expect(testState.updateServer).toHaveBeenCalledTimes(2); + + retry.resolve(AsyncResult.success({ targetVersion: "0.0.29", method: "boot-service" })); + await flushPromises(); + expect(renderAction().props.disabled).toBe(true); + }); + + it("quietly releases the action when a restart RPC is interrupted", async () => { + testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); + + renderAction().props.onClick?.(); + await flushPromises(); + + expect(renderAction().props.disabled).not.toBe(true); + expect(testState.toast).not.toHaveBeenCalledWith( + expect.objectContaining({ title: "Server update failed" }), + ); + }); +}); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx new file mode 100644 index 00000000000..5e82c4e4d93 --- /dev/null +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -0,0 +1,201 @@ +import { useEffect, useRef, useState } from "react"; +import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { manualServerUpdateCommand } from "~/versionSkew"; +import { Button } from "./ui/button"; +import { Spinner } from "./ui/spinner"; +import { toastManager } from "./ui/toast"; + +/** + * The npm install on the server side is capped at 10 minutes; expire the + * spinner a bit beyond that so a dead transport never strands a disabled + * button, while a legitimately slow install is never cut off. + */ +const UPDATE_PENDING_EXPIRY_MS = 12 * 60_000; + +function updateFailureMessage(error: unknown): string { + return error instanceof Error ? error.message : "Server update failed."; +} + +/** + * The call-to-action for a version-skewed server, matched to the update path + * it advertises: a one-click install-and-restart for servers that can update + * themselves, an update-the-desktop-app hint for desktop-managed backends + * (running `npx t3` there would start a second server, not update this one), + * and copying the manual relaunch command for everything else — so the skew + * warning always offers a way out. + */ +export function ServerUpdateAction({ + environmentId, + serverLabel, + selfUpdate, + targetVersion, +}: { + readonly environmentId: EnvironmentId; + readonly serverLabel: string; + readonly selfUpdate: ServerSelfUpdateCapability | null; + readonly targetVersion: string; +}) { + const updateServer = useAtomCommand(serverEnvironment.updateServer, { + reportFailure: false, + }); + const [pending, setPending] = useState(false); + const inFlightRef = useRef(false); + const attemptRef = useRef(0); + const expiryRef = useRef | null>(null); + const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ + target: "update command", + onCopy: ({ command }) => { + toastManager.add({ + type: "success", + title: "Update command copied", + description: `Run \`${command}\` on ${serverLabel} to update it.`, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Could not copy update command", + description: error.message, + }); + }, + }); + + useEffect( + () => () => { + if (expiryRef.current !== null) { + clearTimeout(expiryRef.current); + expiryRef.current = null; + } + attemptRef.current += 1; + inFlightRef.current = false; + }, + [], + ); + + const handleUpdate = () => { + // Synchronous re-entry guard: setPending is async, so a rapid + // double-click would otherwise dispatch two updates. + if (inFlightRef.current) { + return; + } + inFlightRef.current = true; + const attempt = attemptRef.current + 1; + attemptRef.current = attempt; + const ownsAttempt = () => attemptRef.current === attempt; + setPending(true); + const armExpiry = () => { + const expiry = setTimeout(() => { + if (!ownsAttempt()) return; + expiryRef.current = null; + attemptRef.current += 1; + inFlightRef.current = false; + setPending(false); + toastManager.add({ + type: "error", + title: "Server update timed out", + description: "The update may still be running on the server — check again in a minute.", + }); + }, UPDATE_PENDING_EXPIRY_MS); + expiryRef.current = expiry; + return expiry; + }; + let expiry = armExpiry(); + let restartAccepted = false; + const keepPendingForRestart = () => { + restartAccepted = true; + if (expiryRef.current === expiry) { + clearTimeout(expiry); + expiry = armExpiry(); + } + }; + void Promise.resolve() + .then(() => + updateServer({ + environmentId, + input: { targetVersion }, + }), + ) + .then((result) => { + if (!ownsAttempt()) return; + if (result._tag === "Failure") { + // An interrupt may be the expected boot-service disconnect, but it + // can also be client-side cancellation before restart was accepted. + // Release the action quietly; version sync will remove it when a + // successful replacement reconnects. + if (isAtomCommandInterrupted(result)) { + return; + } + toastManager.add({ + type: "error", + title: "Server update failed", + description: updateFailureMessage(squashAtomCommandFailure(result)), + }); + return; + } + keepPendingForRestart(); + // Installation can legitimately consume most of the request window. + // Give restart/reconnect a fresh full window after the server accepts + // the handoff instead of expiring based on the original click time. + toastManager.add({ + type: "success", + title: `Updating ${serverLabel}`, + description: `t3@${result.value.targetVersion} is installed — the server is restarting and will reconnect shortly.`, + }); + }) + .catch((error: unknown) => { + if (!ownsAttempt()) return; + toastManager.add({ + type: "error", + title: "Server update failed", + description: updateFailureMessage(error), + }); + }) + .finally(() => { + // A successful RPC only acknowledges that restart is scheduled. Keep + // the action disabled until version sync unmounts it, or until the + // safety expiry reports that reconnection never arrived. + if (restartAccepted || !ownsAttempt() || expiryRef.current !== expiry) return; + expiryRef.current = null; + clearTimeout(expiry); + attemptRef.current += 1; + inFlightRef.current = false; + setPending(false); + }); + }; + + if (selfUpdate === "desktop-managed") { + return ( + + Update the desktop app on that machine to update this server. + + ); + } + + if (selfUpdate === null) { + const command = manualServerUpdateCommand(targetVersion); + return ( + + ); + } + + return pending ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 385b244577e..734a4e373f0 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -106,7 +106,10 @@ import { } from "~/environments/primary"; import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { useUiStateStore } from "~/uiStateStore"; -import { resolveServerConfigVersionMismatch } from "~/versionSkew"; +import { + resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, +} from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { authEnvironment } from "~/state/auth"; @@ -129,6 +132,7 @@ import { } from "~/state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { ServerUpdateAction } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; @@ -1425,11 +1429,19 @@ function SavedBackendListRow({

      {metadataBits.join(" · ")}

      ) : null} {versionMismatch ? ( -

      - - Version drift: client {versionMismatch.clientVersion}, server{" "} - {versionMismatch.serverVersion}. -

      +
      +

      + + Version drift: client {versionMismatch.clientVersion}, server{" "} + {versionMismatch.serverVersion}. +

      + +
      ) : null} {environment.connection.error ? (

      @@ -2982,6 +2994,16 @@ export function ConnectionsSettings() { fail. } + control={ + primaryEnvironmentId !== null ? ( + + ) : undefined + } /> ) : null} {desktopBridge ? ( diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index b1f2189c0a3..da4ae1e2241 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -8,7 +8,9 @@ import { dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, resolveVersionMismatch, + serverUpdateGuidance, } from "./versionSkew"; describe("versionSkew", () => { @@ -75,4 +77,34 @@ describe("versionSkew", () => { "Socket closed. Hint: Version mismatch. Try syncing the client and server to the same T3 Code version.", ); }); + + it("reads desktop-managed update capabilities from config descriptors", () => { + expect( + resolveServerSelfUpdateCapability({ + environment: { + environmentId: EnvironmentId.make("environment-desktop"), + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "9.9.9", + capabilities: { + repositoryIdentity: true, + serverSelfUpdate: "desktop-managed", + }, + }, + }), + ).toBe("desktop-managed"); + expect(resolveServerSelfUpdateCapability(null)).toBeNull(); + }); + + it("matches version-drift guidance to the advertised update path", () => { + expect(serverUpdateGuidance("respawn", "Remote server")).toBe( + "Update the Remote server so they stay in sync.", + ); + expect(serverUpdateGuidance("desktop-managed", "Desktop server")).toBe( + "The Desktop server is run by the T3 Code desktop app on its machine — update the desktop app there to sync them.", + ); + expect(serverUpdateGuidance(null, "Local server")).toBe( + "Relaunch the Local server with the copied command to sync them.", + ); + }); }); diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index 88691cfc25e..6cf2a474269 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { APP_VERSION } from "./branding"; @@ -49,6 +49,36 @@ export function resolveServerConfigVersionMismatch( return resolveVersionMismatch(serverConfig?.environment.serverVersion); } +/** The update path the connected server offers, or null when it only + supports a manual relaunch (older servers, dev checkouts, Windows). */ +export function resolveServerSelfUpdateCapability( + serverConfig: Pick | null | undefined, +): ServerSelfUpdateCapability | null { + return serverConfig?.environment.capabilities.serverSelfUpdate ?? null; +} + +/** The command to hand users whose server cannot update itself. */ +export function manualServerUpdateCommand(targetVersion: string): string { + return `npx t3@${targetVersion}`; +} + +/** One sentence telling the user how to resolve version skew for a server, + matched to the update path it offers. */ +export function serverUpdateGuidance( + capability: ServerSelfUpdateCapability | null, + serverLabel: string, +): string { + switch (capability) { + case "boot-service": + case "respawn": + return `Update the ${serverLabel} so they stay in sync.`; + case "desktop-managed": + return `The ${serverLabel} is run by the T3 Code desktop app on its machine — update the desktop app there to sync them.`; + default: + return `Relaunch the ${serverLabel} with the copied command to sync them.`; + } +} + export function buildVersionMismatchDismissalKey( environmentId: EnvironmentId, mismatch: Pick, diff --git a/docs/README.md b/docs/README.md index db32ff8468f..fe473094bbc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,19 @@ # Documentation - [Getting started](./getting-started/quick-start.md) -- [Architecture](./architecture/overview.md) +- Architecture + - [Overview](./architecture/overview.md) + - [Connection runtime](./architecture/connection-runtime.md) + - [Remote environments](./architecture/remote.md) + - [Server updates](./architecture/server-updates.md) +- User guides + - [Background service](./user/background-service.md) + - [Remote access](./user/remote-access.md) + - [Keeping T3 Code in sync](./user/server-updates.md) + - [Keybindings](./user/keybindings.md) - [T3 Connect](./cloud/t3-connect-clerk.md) - [Integrations](./integrations/source-control-providers.md) - [Mobile](./mobile/app.md) - [Operations](./operations/ci.md) - [Providers](./providers/codex.md) - [Reference](./reference/encyclopedia.md) -- [User guides](./user/keybindings.md) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index a7b777fb773..7983bef377e 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -37,6 +37,8 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS - **Runtime signals**: The server emits lightweight typed receipts when important async milestones finish, such as checkpoint capture, diff finalization, or a turn becoming fully quiescent. Tests and orchestration code wait on these signals instead of polling internal state. +- **Server updates**: A connected environment advertises whether its server can replace itself. When client and server versions differ, the browser selects an automatic, desktop-managed, or manual update path without changing connection ownership. See [Server Update Architecture](./server-updates.md). + ## Event Lifecycle ### Startup and client connect diff --git a/docs/architecture/remote.md b/docs/architecture/remote.md index 75274095a12..fc4fc0b4065 100644 --- a/docs/architecture/remote.md +++ b/docs/architecture/remote.md @@ -331,6 +331,21 @@ In all of those cases, the `ExecutionEnvironment` is the same kind of thing. Only the launch and access paths differ. +## Version Coordination + +Remote environments may stay online while web, desktop, or mobile clients move to a newer release. +The environment descriptor therefore carries the running server version and may advertise a safe +replacement path. The web and desktop UI use that information to show the appropriate action +without making the connection transport responsible for process management. + +Published CLI servers on supported hosts can install and hand off to the client's exact version. A +desktop-managed backend instead points the user to the desktop app on that machine, while older or +unsupported servers fall back to a manual relaunch. The existing connection supervisor owns the +disconnect and reconnect just as it would for any other involuntary socket close. + +See [Server Update Architecture](./server-updates.md) for capability detection, installation safety, +and restart sequencing. + ## Security model Remote support must assume that some environments will be reachable over untrusted networks. diff --git a/docs/architecture/server-updates.md b/docs/architecture/server-updates.md new file mode 100644 index 00000000000..2e8c9d6ef61 --- /dev/null +++ b/docs/architecture/server-updates.md @@ -0,0 +1,121 @@ +# Server Update Architecture + +T3 Code can update a connected server to the exact version of the client that detected version +drift. This path exists primarily for remote environments, where the user may not have a terminal +open on the server machine. + +The feature has three boundaries: + +- the server advertises whether and how it can be replaced; +- the client chooses the matching user action; +- the server installs and verifies the replacement before handing off the process. + +## Detection and Presentation + +`ExecutionEnvironmentDescriptor` includes the server version and an optional +`capabilities.serverSelfUpdate` value. The client compares that version with `APP_VERSION` after +loading server config. + +The optional capability is intentionally backward compatible. An older server does not know about +the field, so a missing value means the client must offer a manual relaunch instead of sending an +unknown RPC. + +The shared `ServerUpdateAction` is rendered in both user-facing version-drift surfaces: + +- the conversation banner in `ChatView`; +- primary and saved environment rows in **Settings** → **Connections**. + +Both surfaces target the client's exact version. When the reconnected server reports that version, +the mismatch and action disappear. + +## Capability Selection + +The server resolves its capability once at startup and publishes it in the environment descriptor. + +| Advertised value | Process shape | Client behavior | +| ----------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `boot-service` | Linux server running under the T3-managed systemd user service | Call the update RPC; the service unit is replaced and restarted. | +| `respawn` | Published npm CLI running in the foreground on macOS or Linux | Call the update RPC; the process hands off to a detached replacement. | +| `desktop-managed` | Backend supervised by the desktop app | Tell the user to update the desktop app on the server machine. | +| absent | Older server, development checkout, Windows foreground process, or an unrecognized supervisor | Offer the exact manual relaunch command. | + +Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must +never spawn a second CLI server beside the app-owned process. Likewise, a process launched by an +unrecognized systemd unit does not claim the foreground respawn path because its supervisor could +bring the old version back. + +## Update Flow + +```mermaid +flowchart TD + A[Client detects different versions] --> B{Advertised update path} + B -->|desktop-managed| C[Update desktop app on server machine] + B -->|missing| D[Copy exact manual relaunch command] + B -->|boot-service or respawn| E[server.updateServer] + E --> F[Install exact t3 version in pinned runtime] + F --> G[Run version preflight] + G -->|fails| H[Remove failed runtime and keep current server] + G -->|passes| I{Handoff method} + I -->|boot-service| J[Rewrite and restart T3 systemd unit] + I -->|respawn| K[Start delayed replacement and exit current process] + J --> L[Client reconnects] + K --> L +``` + +`server.updateServer` requires the environment's `orchestration:operate` authorization scope. Its +payload accepts only an exact npm version, including an exact prerelease version; dist-tags such as +`latest` and `nightly` are rejected. + +The update service permits one update at a time. It installs `t3@` under +`/runtime/versions/` and writes an install-complete sentinel only after npm exits +successfully. Boot-service setup and self-update share the same process-wide installation lock, so +they cannot mutate a pinned runtime concurrently. + +Before any restart, the current Node executable runs the replacement with `--version`. A failed +install, failed preflight, or wrong reported version leaves the current server running. A failed +preflight also removes the candidate runtime so retrying the same version performs a clean install. + +## Host Service Lifecycle + +The systemd user service is a host lifecycle concern, not a T3 Connect resource. The standalone +`t3 service install`, `uninstall`, `update`, and `status` commands own it. Install and update both +reconcile the unit through `BootService`; running `npx t3@latest service update` therefore pins and +activates the latest CLI release without requiring a connected client. + +The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation +operation as `t3 service install`. Connect logout only disables cloud access and clears its +authorization; it does not uninstall the host service. + +## Process Handoff + +For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified +runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous +unit before returning an error. + +For `respawn`, the server starts a detached, delayed replacement that replays the original CLI +arguments. It then acknowledges the request and schedules the current process to exit. The delays +give the acknowledgement time to cross direct or relayed connections before the socket closes. + +There is no separate progress stream. The update request remains pending while npm installs and the +client shows a disabled update action. A restart can interrupt the request normally; the connection +runtime keeps the environment registered and reconnects through its usual retry path. After an +acknowledged foreground handoff, the UI keeps the action pending until version sync removes it or a +safety timeout releases it. If a boot-service restart closes the connection before acknowledgement, +the UI releases the interrupted action without reporting a false update failure and lets reconnect +and the next version check determine the result. + +## Release Invariant + +The exact client version must exist as the `t3` npm package before a client carrying that version is +published. The release workflow therefore makes the GitHub release depend on CLI publication, and +the hosted web deployment depends on that release. See [Release Checklist](../operations/release.md#server-self-update-release-invariant). + +## Source Map + +- Capability contract: `packages/contracts/src/environment.ts` +- Update RPC contract: `packages/contracts/src/server.ts` and `packages/contracts/src/rpc.ts` +- Capability detection and handoff: `apps/server/src/cloud/selfUpdate.ts` +- Host service commands: `apps/server/src/cli/service.ts` +- Pinned runtime installation: `apps/server/src/cloud/pinnedRuntime.ts` +- Client version comparison: `apps/web/src/versionSkew.ts` +- Shared update action: `apps/web/src/components/ServerUpdateAction.tsx` diff --git a/docs/cloud/t3-connect-clerk.md b/docs/cloud/t3-connect-clerk.md index 3fd1943f7dc..2fe48243a59 100644 --- a/docs/cloud/t3-connect-clerk.md +++ b/docs/cloud/t3-connect-clerk.md @@ -92,6 +92,9 @@ connector, and attempts to revoke the relay-side environment record. It retains authorization so `t3 connect link` can re-enable exposure without another browser flow. `t3 connect logout` performs the same cleanup and removes the stored CLI authorization. +The background service has an independent lifecycle. Connect setup may offer to install it, but +logout leaves it running; manage it with `t3 service status`, `install`, `update`, and `uninstall`. + The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH, forward that port before running `t3 connect login` or `t3 connect link`: diff --git a/docs/operations/release.md b/docs/operations/release.md index 76f787dc023..9e908550054 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -156,6 +156,26 @@ One-time Vercel dashboard setup: - Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version. - Does not commit version bumps back to `main`. +## Server self-update release invariant + +Connected servers update to the client's exact version, not to an npm dist-tag. Every released +desktop or hosted client version must therefore have a matching `t3@` package available on +npm before users can receive that client. + +The workflow enforces this ordering: + +1. `publish_cli` publishes the exact stable or nightly version to npm. +2. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases. +3. `deploy_web` depends on `release` before moving the hosted channel to the new client. + +Preserve these dependencies when changing the release graph. Publishing a client first would leave +the **Update server** action targeting a package version that does not exist yet. + +For a release smoke test, confirm `npm view t3@ version` returns the expected version, then +connect the new client to a server on the previous version and verify that the update action +reconnects to the matching server. Test one automatic path and the manual or desktop-managed +guidance when those environments are available. + ## Desktop auto-update notes - Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`. @@ -293,6 +313,7 @@ Checklist: 5. Verify workflow steps: - preflight passes - all matrix builds pass + - `publish_cli` publishes the exact release version before the release job - release job uploads expected files 6. Smoke test downloaded artifacts. diff --git a/docs/user/background-service.md b/docs/user/background-service.md new file mode 100644 index 00000000000..8f1f68dc065 --- /dev/null +++ b/docs/user/background-service.md @@ -0,0 +1,42 @@ +# Running T3 Code in the Background + +On a Linux host, T3 Code can run as a background service for your user. It starts when the machine +boots and keeps running after you log out. + +## Manage the Service + +Install it with the latest T3 Code release: + +```sh +npx t3@latest service install +``` + +Check whether it is installed: + +```sh +npx t3@latest service status +``` + +Update or repair it: + +```sh +npx t3@latest service update +``` + +Stop it and remove it from startup: + +```sh +npx t3@latest service uninstall +``` + +Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first. + +## Using It with T3 Connect + +T3 Connect may offer to install the service during setup so the host stays reachable after you log +out. This is only an onboarding shortcut: the service and T3 Connect are managed separately. + +Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer +want T3 Code to start in the background. + +The background service currently requires Linux with systemd. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 56510e62890..d6ff00bee1f 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -152,6 +152,19 @@ With mise/asdf/fnm/nodenv, make sure the tool's shim directory is installed and If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually. +## Updating a Remote Server + +When the T3 Code web or desktop app and a remote server use different versions, a warning appears in +the conversation and in **Settings** → **Connections**. Follow the action shown there: T3 Code may +be able to update and reconnect the server for you, or it may ask you to update the desktop app or +run a copied command on the server machine. + +Finish active work before updating because the server restarts briefly. For step-by-step guidance, +see [Keeping T3 Code in Sync](./server-updates.md). + +On a Linux host, you can keep the server running after logout and manage it independently of the +connection method. See [Running T3 Code in the Background](./background-service.md). + ## How Pairing Works The remote device does not need a long-lived secret up front. diff --git a/docs/user/server-updates.md b/docs/user/server-updates.md new file mode 100644 index 00000000000..6a8e33977b7 --- /dev/null +++ b/docs/user/server-updates.md @@ -0,0 +1,56 @@ +# Keeping T3 Code in Sync + +The T3 Code web or desktop app and the server it connects to work best when they use the same +version. If they do not match, T3 Code shows a warning with the right update option for that server. + +## Where to Find the Update + +You may see the warning in either of these places: + +- above the message box in the current conversation +- **Settings** → **Connections**, beside the affected connection + +Dismissing the conversation warning only hides that reminder for those two versions. It does not +update the server, and the version difference remains visible in Connections. + +## Before You Update + +Let active agent work and terminal commands finish first. Updating restarts the server, so the +connection will disappear briefly and work that is still running may be interrupted. + +The update does not remove saved threads, settings, or project files. + +## Choose the Action You See + +| Action | What to do | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Update server** | Select the button and leave T3 Code open. It prepares the matching version, restarts the server, and reconnects automatically. This can take several minutes. | +| **Update the desktop app** | Open the T3 Code desktop app on the machine that runs the server and install the app update there. Reopen it if needed. | +| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current T3 Code server, and relaunch it with the copied command and any startup options you normally use. | + +The available action depends on how that server was started. T3 Code does not update connected +servers silently in the background. + +If the server uses the T3 Code background service, you can also update it directly on the host: + +```sh +npx t3@latest service update +``` + +See [Running T3 Code in the Background](./background-service.md) for install, status, and removal +commands. + +## After the Update + +Keep the web or desktop app open while the server restarts. When it reconnects with the matching +version, the warning and update action disappear. + +If the client reports a timeout, the server may still be finishing the update. Wait a minute, then +reconnect or open **Settings** → **Connections** again. If the warning remains: + +1. Retry the offered action once. +2. Make sure you updated the machine named in the warning, not only the device you are using. +3. For a command-line server, relaunch it with `npx t3@`, replacing + `` with the client version shown in the warning. + +For remote connection setup and access troubleshooting, see [Remote Access](./remote-access.md). diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7306a0a1071..ea7f5fb6d75 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -321,6 +321,12 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), + updateServer: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:update-server", + tag: WS_METHODS.serverUpdateServer, + scheduler: configScheduler, + concurrency: configConcurrency, + }), upsertKeybinding: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:upsert-keybinding", tag: WS_METHODS.serverUpsertKeybinding, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 6fc0c914d8a..936b97f6c2d 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -20,6 +20,23 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ }); export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; +/** How a server can replace itself with another version when asked over RPC: + "boot-service" rewrites the systemd user unit and restarts it; "respawn" + installs the target version and respawns the foreground process. */ +export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]); +export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; + +/** What update path a client should offer for a server: one of the RPC + self-update methods above, or "desktop-managed" when the backend's + version belongs to the T3 Code desktop app supervising it — updating the + app on that machine is the only way to update the server. */ +export const ServerSelfUpdateCapability = Schema.Literals([ + "boot-service", + "respawn", + "desktop-managed", +]); +export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; + export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), @@ -27,6 +44,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ threadSettlement: Schema.optionalKey(Schema.Boolean), + /** The update path clients should offer for this server. Absent on + servers that must be relaunched manually (dev checkouts, Windows + foreground runs, pre-update servers). */ + serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0356aa1807b..fa2d23b8ef2 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -122,6 +122,9 @@ import { ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, ServerProviderUpdatedPayload, + ServerSelfUpdateError, + ServerSelfUpdateInput, + ServerSelfUpdateResult, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, @@ -205,6 +208,7 @@ export const WS_METHODS = { serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", + serverUpdateServer: "server.updateServer", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", @@ -279,6 +283,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]), }); +export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { + payload: ServerSelfUpdateInput, + success: ServerSelfUpdateResult, + error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), +}); + export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -693,6 +703,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, + WsServerUpdateServerRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3d99b8e95a6..69699c7a839 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { IsoDateTime, @@ -574,3 +574,30 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass()( + "ServerSelfUpdateError", + { + reason: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Server update failed: ${this.reason}`; + } +} From 593289c3c771ec1987b3f904eb95305d7ccaecf6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 22 Jul 2026 23:51:31 +0200 Subject: [PATCH 054/110] Refine light-mode sidebar surfaces (#4268) Co-authored-by: Claude Fable 5 Co-authored-by: codex --- apps/mobile/src/features/home/HomeHeader.tsx | 149 ++- .../src/features/home/HomeRouteScreen.tsx | 30 +- apps/mobile/src/features/home/HomeScreen.tsx | 175 ++-- .../home/home-list-filter-menu.test.ts | 44 + .../features/home/home-list-filter-menu.ts | 131 ++- .../features/home/home-list-options.test.ts | 3 + .../src/features/home/home-list-options.ts | 5 +- .../features/home/thread-swipe-actions.tsx | 36 +- .../threads/ThreadNavigationSidebar.tsx | 495 ++++++++- .../features/threads/thread-list-v2-items.tsx | 294 ++++-- .../src/features/threads/threadListV2.ts | 6 + apps/mobile/src/native/StackHeader.tsx | 11 +- apps/web/src/components/AppSidebarLayout.tsx | 9 +- apps/web/src/components/ChatView.tsx | 1 + .../components/CommandPalette.logic.test.ts | 28 + .../src/components/CommandPalette.logic.ts | 18 +- apps/web/src/components/CommandPalette.tsx | 90 +- .../src/components/CommandPaletteResults.tsx | 4 +- apps/web/src/components/RightPanelTabs.tsx | 4 +- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/components/SidebarV2.tsx | 963 ++++++++++-------- apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../components/chat/ModelPickerContent.tsx | 6 +- .../clerk/T3ConnectSidebarSignIn.tsx | 2 +- .../settings/SettingsSidebarNav.tsx | 10 +- .../components/settings/settingsLayout.tsx | 2 +- .../src/components/sidebar/SidebarChrome.tsx | 9 +- apps/web/src/components/ui/command.tsx | 10 +- apps/web/src/components/ui/sidebar.tsx | 20 +- apps/web/src/index.css | 144 +-- 30 files changed, 1825 insertions(+), 878 deletions(-) create mode 100644 apps/mobile/src/features/home/home-list-filter-menu.test.ts diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 3a0342a8214..cf9cf378edd 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -4,6 +4,8 @@ import type { SidebarThreadSortOrder, } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -14,6 +16,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; import { useThemeColor } from "../../lib/useThemeColor"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; @@ -21,6 +24,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList"; import { buildHomeListFilterMenu, type HomeListFilterMenuEnvironment, + type HomeListFilterMenuProject, } from "./home-list-filter-menu"; import { hasCustomHomeListOptions, @@ -33,13 +37,16 @@ export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; export function HomeHeader(props: { readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; @@ -59,11 +66,24 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } +/** Thread List v2 lays the list out in fixed creation order, so the + sort/group filter controls would be silently ignored — hide them and + key the "customized" icon state off the environment filter alone. */ +function useThreadListV2FilterGate() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + return ( + AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true + ); +} + function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); - const hasCustomListOptions = hasCustomHomeListOptions(props); + const threadListV2Enabled = useThreadListV2FilterGate(); + const hasCustomListOptions = threadListV2Enabled + ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + : hasCustomHomeListOptions(props); const menuActions = useMemo( () => [ { @@ -82,40 +102,67 @@ function AndroidHomeHeader(props: HomeHeaderProps) { })), ], }, - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectSortOrder === option.value), - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.threadSortOrder === option.value), - })), - }, - { - id: "project-grouping", - title: "Group projects", - subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ - id: `project-grouping:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectGroupingMode === option.value), - })), - }, + ...(props.projects.length === 0 + ? [] + : ([ + { + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + state: checkedMenuState(props.selectedProjectKey === null), + }, + ...props.projects.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: checkedMenuState(props.selectedProjectKey === project.key), + })), + ], + }, + ] satisfies MenuAction[])), + ...(threadListV2Enabled + ? [] + : ([ + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectSortOrder === option.value), + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.threadSortOrder === option.value), + })), + }, + { + id: "project-grouping", + title: "Group projects", + subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ + id: `project-grouping:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectGroupingMode === option.value), + })), + }, + ] satisfies MenuAction[])), ], [ props.environments, props.projectGroupingMode, props.projectSortOrder, + props.projects, props.selectedEnvironmentId, + props.selectedProjectKey, props.threadSortOrder, + threadListV2Enabled, ], ); const handleMenuAction = useCallback( @@ -137,6 +184,19 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return; } + if (id === "project:all") { + props.onProjectChange(null); + return; + } + + if (id.startsWith("project:")) { + const projectKey = id.slice("project:".length); + if (props.projects.some((project) => project.key === projectKey)) { + props.onProjectChange(projectKey); + } + return; + } + const projectSort = PROJECT_SORT_OPTIONS.find( (option) => id === `project-sort:${option.value}`, ); @@ -255,17 +315,24 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); - const hasCustomListOptions = hasCustomHomeListOptions(props); + const threadListV2Enabled = useThreadListV2FilterGate(); + const hasCustomListOptions = threadListV2Enabled + ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + : hasCustomHomeListOptions(props); const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu(props); + const filterMenu = buildHomeListFilterMenu({ + ...props, + listOrganization: !threadListV2Enabled, + }); return ( <> + {props.projects.length > 0 ? ( + + Project + props.onProjectChange(null)} + subtitle="Show threads from every project" + > + All projects + + {props.projects.map((project) => ( + props.onProjectChange(project.key)} + > + {project.label} + + ))} + + ) : null} + Sort projects {PROJECT_SORT_OPTIONS.map((option) => ( diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 502d2bab1b5..d4b19cdb0fb 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,9 +1,10 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -58,6 +59,28 @@ export function HomeRouteScreen() { setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; + const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const projectFilterOptions = useMemo( + () => + projects + .filter( + (project) => + selectedEnvironmentId === null || project.environmentId === selectedEnvironmentId, + ) + .map((project) => ({ + key: scopedProjectKey(project.environmentId, project.id), + label: project.title, + })), + [projects, selectedEnvironmentId], + ); + useEffect(() => { + if ( + selectedProjectKey !== null && + !projectFilterOptions.some((project) => project.key === selectedProjectKey) + ) { + setSelectedProjectKey(null); + } + }, [projectFilterOptions, selectedProjectKey]); // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. @@ -90,12 +113,15 @@ export function HomeRouteScreen() { navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectGroupingModeChange={setProjectGroupingMode} onProjectSortOrderChange={setProjectSortOrder} @@ -115,6 +141,7 @@ export function HomeRouteScreen() { onSettleThread={settleThread} onUnsettleThread={unsettleThread} onEnvironmentChange={setSelectedEnvironmentId} + onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) } @@ -151,6 +178,7 @@ export function HomeRouteScreen() { savedConnectionsById={savedConnectionsById} searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} + selectedProjectKey={selectedProjectKey} threads={threads} threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d1339a9bb91..41180c48643 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -38,7 +38,12 @@ import { ThreadListShowMoreRow, } from "../threads/thread-list-items"; import { ThreadListV2Row } from "../threads/thread-list-v2-items"; -import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2"; +import { + buildThreadListV2Items, + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + THREAD_LIST_V2_SETTLED_PAGE_COUNT, + type ThreadListV2Item, +} from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, @@ -65,11 +70,13 @@ interface HomeScreenProps { readonly environments: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; @@ -91,10 +98,6 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; -// v2 settled-tail paging: recent history is the common lookup; the deep -// tail stays behind an explicit Show more. -const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; -const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and @@ -169,80 +172,6 @@ function HomeTopContentSpacer() { return ; } -function ThreadListV2ProjectScope(props: { - readonly projects: ReadonlyArray; - readonly selectedKey: string | null; - readonly onChange: (key: string | null) => void; -}) { - if (props.projects.length === 0) return null; - - return ( - - {props.projects.length > 1 ? ( - props.onChange(null)} - className={cn( - "min-h-8 items-center justify-center rounded-lg border px-3", - props.selectedKey === null - ? "border-border bg-subtle-strong" - : "border-black/15 dark:border-white/15", - )} - > - All - - ) : null} - {props.projects.map((project) => { - const key = scopedProjectKey(project.environmentId, project.id); - const selected = props.selectedKey === key; - return ( - props.onChange(selected ? null : key)} - className={cn( - "min-h-8 flex-row items-center gap-1.5 rounded-lg border py-1 pl-2 pr-3", - selected ? "border-border bg-subtle-strong" : "border-black/15 dark:border-white/15", - )} - > - - - {project.title} - - - ); - })} - - ); -} - /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { @@ -314,12 +243,51 @@ export function HomeScreen(props: HomeScreenProps) { onScrollBeginDrag: handleScrollBeginDrag, }); + const scopedProject = useMemo( + () => + props.selectedProjectKey === null + ? null + : (props.projects.find( + (project) => + scopedProjectKey(project.environmentId, project.id) === props.selectedProjectKey && + (props.selectedEnvironmentId === null || + project.environmentId === props.selectedEnvironmentId), + ) ?? null), + [props.projects, props.selectedEnvironmentId, props.selectedProjectKey], + ); + const scopedProjects = useMemo( + () => (scopedProject === null ? props.projects : [scopedProject]), + [props.projects, scopedProject], + ); + const scopedThreads = useMemo( + () => + scopedProject === null + ? props.threads + : props.threads.filter( + (thread) => + thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id, + ), + [props.threads, scopedProject], + ); + const scopedPendingTasks = useMemo( + () => + scopedProject === null + ? props.pendingTasks + : props.pendingTasks.filter( + (pendingTask) => + pendingTask.message.environmentId === scopedProject.environmentId && + pendingTask.creation.projectId === scopedProject.id, + ), + [props.pendingTasks, scopedProject], + ); + const projectGroups = useMemo( () => buildHomeThreadGroups({ - projects: props.projects, - threads: props.threads, - pendingTasks: props.pendingTasks, + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: props.projectSortOrder, @@ -327,14 +295,14 @@ export function HomeScreen(props: HomeScreenProps) { projectGroupingMode: props.projectGroupingMode, }), [ - props.pendingTasks, props.projectGroupingMode, - props.projects, props.projectSortOrder, props.searchQuery, props.selectedEnvironmentId, props.threadSortOrder, - props.threads, + scopedPendingTasks, + scopedProjects, + scopedThreads, ], ); @@ -365,7 +333,8 @@ export function HomeScreen(props: HomeScreenProps) { return map; }, [props.projects]); - const [v2ProjectScopeKey, setV2ProjectScopeKey] = useState(null); + const v2ProjectScopeKey = props.selectedProjectKey; + const setV2ProjectScopeKey = props.onProjectChange; const v2ScopeProjects = useMemo( () => props.selectedEnvironmentId === null @@ -382,12 +351,6 @@ export function HomeScreen(props: HomeScreenProps) { ) ?? null), [v2ProjectScopeKey, v2ScopeProjects], ); - useEffect(() => { - if (v2ProjectScopeKey !== null && v2ScopedProject === null) { - setV2ProjectScopeKey(null); - } - }, [v2ProjectScopeKey, v2ScopedProject]); - // Thread List v2 (beta): one flat list in creation order, no grouping. // Settled threads collapse into a recency tail below the card block. // Settled threads stay in the live shell stream (settled ≠ archived), so @@ -442,6 +405,10 @@ export function HomeScreen(props: HomeScreenProps) { const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); useEffect(() => { if (!threadListV2Enabled) return; + // Refresh immediately on enable: the mount-time value can be hours old + // by the time the beta is switched on, which would misclassify the + // inactivity auto-settle boundary until the first tick. + setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); }, [threadListV2Enabled]); @@ -509,6 +476,11 @@ export function HomeScreen(props: HomeScreenProps) { (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), )?.driver ?? null } + environmentLabel={ + Object.keys(props.savedConnectionsById).length > 1 + ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null) + : null + } onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} @@ -535,6 +507,7 @@ export function HomeScreen(props: HomeScreenProps) { projectCwdByKey, props.onArchiveThread, props.onSelectThread, + props.savedConnectionsById, serverConfigs, settlementEnvironmentIds, ], @@ -731,14 +704,11 @@ export function HomeScreen(props: HomeScreenProps) { pendingTask.creation.projectId === v2ScopedProject.id)) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); + // Project scoping lives in the header filter menu (no inline chip row on + // mobile — the menu is the one filter surface). const v2ListHeader = ( <> {listHeader} - {v2PendingTasks.map((pendingTask, index) => ( + ) : scopedProject !== null ? ( + ) : selectedEnvironmentLabel ? ( 0 ? ( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts new file mode 100644 index 00000000000..916e32671a1 --- /dev/null +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { buildHomeListFilterMenu } from "./home-list-filter-menu"; + +describe("buildHomeListFilterMenu", () => { + it("adds a project scope submenu that selects and clears the same scope as the chips", () => { + const onProjectChange = vi.fn(); + const menu = buildHomeListFilterMenu({ + environments: [], + projects: [ + { key: "environment-1:project-1", label: "Codething" }, + { key: "environment-1:project-2", label: "Website" }, + ], + selectedEnvironmentId: null, + selectedProjectKey: "environment-1:project-1", + projectSortOrder: "updated_at", + threadSortOrder: "updated_at", + projectGroupingMode: "repository", + onEnvironmentChange: vi.fn(), + onProjectChange, + onProjectSortOrderChange: vi.fn(), + onThreadSortOrderChange: vi.fn(), + onProjectGroupingModeChange: vi.fn(), + }); + + const projectMenu = menu.items.find( + (item) => item.type === "submenu" && item.title === "Project", + ); + expect(projectMenu).toMatchObject({ + type: "submenu", + items: [ + { title: "All projects", state: "off" }, + { title: "Codething", state: "on" }, + { title: "Website", state: "off" }, + ], + }); + if (projectMenu?.type !== "submenu") throw new Error("Expected project submenu"); + + projectMenu.items[0]?.onPress(); + projectMenu.items[2]?.onPress(); + expect(onProjectChange).toHaveBeenNthCalledWith(1, null); + expect(onProjectChange).toHaveBeenNthCalledWith(2, "environment-1:project-2"); + }); +}); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index 46ea639677b..73fda2f5d03 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -16,6 +16,11 @@ export interface HomeListFilterMenuEnvironment { readonly label: string; } +export interface HomeListFilterMenuProject { + readonly key: string; + readonly label: string; +} + type HomeListFilterMenuAction = { readonly type: "action"; readonly title: string; @@ -37,15 +42,22 @@ export interface HomeListFilterMenu { export function buildHomeListFilterMenu(props: { readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; readonly onOpenSettings?: () => void; + /** False hides the sort/group submenus. Thread List v2 uses a fixed + creation-order layout, so offering those controls while it silently + ignores them would be a lie; the environment filter still applies. */ + readonly listOrganization?: boolean; }): HomeListFilterMenu { const items: Array = []; @@ -57,61 +69,86 @@ export function buildHomeListFilterMenu(props: { }); } - items.push( - { + items.push({ + type: "submenu", + title: "Environment", + items: [ + { + type: "action", + title: "All environments", + subtitle: "Show threads from every environment", + state: props.selectedEnvironmentId === null ? "on" : "off", + onPress: () => props.onEnvironmentChange(null), + }, + ...props.environments.map((environment) => ({ + type: "action" as const, + title: environment.label, + state: + props.selectedEnvironmentId === environment.environmentId + ? ("on" as const) + : ("off" as const), + onPress: () => props.onEnvironmentChange(environment.environmentId), + })), + ], + }); + + if (props.projects.length > 0) { + items.push({ type: "submenu", - title: "Environment", + title: "Project", items: [ { type: "action", - title: "All environments", - subtitle: "Show threads from every environment", - state: props.selectedEnvironmentId === null ? "on" : "off", - onPress: () => props.onEnvironmentChange(null), + title: "All projects", + subtitle: "Show threads from every project", + state: props.selectedProjectKey === null ? "on" : "off", + onPress: () => props.onProjectChange(null), }, - ...props.environments.map((environment) => ({ + ...props.projects.map((project) => ({ type: "action" as const, - title: environment.label, - state: - props.selectedEnvironmentId === environment.environmentId - ? ("on" as const) - : ("off" as const), - onPress: () => props.onEnvironmentChange(environment.environmentId), + title: project.label, + state: props.selectedProjectKey === project.key ? ("on" as const) : ("off" as const), + onPress: () => props.onProjectChange(project.key), })), ], - }, - { - type: "submenu", - title: "Sort projects", - items: PROJECT_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.projectSortOrder === option.value ? "on" : "off", - onPress: () => props.onProjectSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Sort threads", - items: THREAD_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.threadSortOrder === option.value ? "on" : "off", - onPress: () => props.onThreadSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Group projects", - items: PROJECT_GROUPING_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - subtitle: option.subtitle, - state: props.projectGroupingMode === option.value ? "on" : "off", - onPress: () => props.onProjectGroupingModeChange(option.value), - })), - }, - ); + }); + } + + if (props.listOrganization !== false) { + items.push( + { + type: "submenu", + title: "Sort projects", + items: PROJECT_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: props.projectSortOrder === option.value ? "on" : "off", + onPress: () => props.onProjectSortOrderChange(option.value), + })), + }, + { + type: "submenu", + title: "Sort threads", + items: THREAD_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: props.threadSortOrder === option.value ? "on" : "off", + onPress: () => props.onThreadSortOrderChange(option.value), + })), + }, + { + type: "submenu", + title: "Group projects", + items: PROJECT_GROUPING_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + subtitle: option.subtitle, + state: props.projectGroupingMode === option.value ? "on" : "off", + onPress: () => props.onProjectGroupingModeChange(option.value), + })), + }, + ); + } return { title: "Thread list options", diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index 788be25906b..651a64f6f83 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -27,5 +27,8 @@ describe("home list options", () => { hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), ).toBe(true); expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true); + expect( + hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }), + ).toBe(true); }); }); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 64881034306..919cec55ef1 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -93,13 +93,16 @@ export function HomeListOptionsProvider({ children }: PropsWithChildren) { return createElement(HomeListOptionsContext, { value }, children); } -export function hasCustomHomeListOptions(options: HomeListOptions): boolean { +export function hasCustomHomeListOptions( + options: HomeListOptions & { readonly selectedProjectKey?: string | null }, +): boolean { const defaultProjectSortOrder = DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; return ( options.selectedEnvironmentId !== null || + (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || options.projectSortOrder !== defaultProjectSortOrder || options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 666169f3039..186c606ae8f 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -36,6 +36,8 @@ import { AppText as Text } from "../../components/AppText"; const ACTION_ITEM_WIDTH = 58; const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; +const COMPACT_ACTION_CIRCLE_SIZE = 28; +const COMPACT_ACTION_ICON_SIZE = 13; export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; export const THREAD_SWIPE_SPRING = { @@ -163,6 +165,9 @@ export function useSwipeableScrollGate(options?: { export function ThreadSwipeable(props: { readonly backgroundColor: ColorValue; readonly children: (close: () => void) => ReactNode; + /** Uses action visuals that fit inside compact 44pt rows. The press target + * still spans the row's full height and width. */ + readonly compactActions?: boolean; readonly containerStyle?: StyleProp; /** Disables NEW swipe activations (e.g. while the list scrolls). */ readonly enabled?: boolean; @@ -258,6 +263,7 @@ export function ThreadSwipeable(props: { renderRightActions={(_progress, translation, methods) => ( ["name"]; @@ -293,6 +300,8 @@ function SwipeActionButton(props: { readonly stretchesOnFullSwipe: boolean; readonly translation: SharedValue; }) { + const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; + const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); @@ -324,7 +333,7 @@ function SwipeActionButton(props: { return { transform: [{ translateX: -stretch }], - width: ACTION_CIRCLE_SIZE + stretch, + width: circleSize + stretch, }; }); const iconStyle = useAnimatedStyle(() => { @@ -386,13 +395,13 @@ function SwipeActionButton(props: { width: "100%", })} > - + - + - + {props.label} @@ -434,6 +443,7 @@ function SwipeActionButton(props: { export function ThreadSwipeActions(props: { readonly backgroundColor: ColorValue; + readonly compact: boolean; readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeThreshold: number; readonly onDelete: () => void; @@ -466,6 +476,7 @@ export function ThreadSwipeActions(props: { (null); const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); - const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = + useThreadListActions(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const threadListV2Enabled = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -193,19 +224,77 @@ function ThreadNavigationSidebarPane( setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); + const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const projectFilterOptions = useMemo( + () => + projects + .filter( + (project) => + options.selectedEnvironmentId === null || + project.environmentId === options.selectedEnvironmentId, + ) + .map((project) => ({ + key: scopedProjectKey(project.environmentId, project.id), + label: project.title, + })), + [options.selectedEnvironmentId, projects], + ); + const selectedProject = useMemo( + () => + selectedProjectKey === null + ? null + : (projects.find( + (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, + ) ?? null), + [projects, selectedProjectKey], + ); + useEffect(() => { + if ( + selectedProjectKey !== null && + !projectFilterOptions.some((project) => project.key === selectedProjectKey) + ) { + setSelectedProjectKey(null); + } + }, [projectFilterOptions, selectedProjectKey]); + const scopedProjects = useMemo( + () => (selectedProject === null ? projects : [selectedProject]), + [projects, selectedProject], + ); + const scopedThreads = useMemo( + () => + selectedProject === null + ? threads + : threads.filter( + (thread) => + thread.environmentId === selectedProject.environmentId && + thread.projectId === selectedProject.id, + ), + [selectedProject, threads], + ); + const scopedPendingTasks = useMemo( + () => + selectedProject === null + ? pendingTasks + : pendingTasks.filter( + (pendingTask) => + pendingTask.message.environmentId === selectedProject.environmentId && + pendingTask.creation.projectId === selectedProject.id, + ), + [pendingTasks, selectedProject], + ); const groups = useMemo( () => buildHomeThreadGroups({ - projects, - threads, - pendingTasks, + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), - [options, pendingTasks, projects, props.searchQuery, threads], + [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads], ); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap @@ -237,6 +326,153 @@ function ThreadNavigationSidebarPane( } return map; }, [projects]); + const projectByKey = useMemo(() => { + const map = new Map(); + for (const project of projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project); + } + return map; + }, [projects]); + + // Thread List v2 (beta) support — same model as the compact Home list + // (HomeScreen.tsx): flat creation-order card block + settled recency tail. + // PR states stream in per-row; merged/closed PRs auto-settle their thread + // on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + // The settled tail renders in pages; expansion resets when the filter + // context changes so environment/search flips never inherit a deep page. + const [settledVisibleCount, setSettledVisibleCount] = useState( + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + ); + const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); + } + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), + [], + ); + // now ticks per minute so the inactivity auto-settle boundary is actually + // crossed while the pane stays open; without a clock dependency the + // partition memoizes a frozen "now". + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + if (!threadListV2Enabled) return; + // Refresh immediately on enable: the mount-time value can be hours old + // by the time the beta is switched on, which would misclassify the + // inactivity auto-settle boundary until the first tick. + setNowMinute(new Date().toISOString().slice(0, 16)); + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, [threadListV2Enabled]); + // Threads on servers without the settlement capability never classify as + // settled (the user could neither un-settle nor pin them). + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const threadListV2Layout = useMemo(() => { + if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + return buildThreadListV2Items({ + threads: threads.filter((thread) => thread.archivedAt === null), + environmentId: options.selectedEnvironmentId, + projectRef: + selectedProject === null + ? null + : { + environmentId: selectedProject.environmentId, + projectId: selectedProject.id, + }, + searchQuery: props.searchQuery, + changeRequestStateByKey, + settlementEnvironmentIds, + settledLimit: settledVisibleCount, + now: `${nowMinute}:00.000Z`, + }); + }, [ + changeRequestStateByKey, + nowMinute, + options.selectedEnvironmentId, + props.searchQuery, + settledVisibleCount, + settlementEnvironmentIds, + threadListV2Enabled, + threads, + selectedProject, + ]); + const listItems = useMemo(() => { + if (!threadListV2Enabled) return listLayout.items; + // Queued offline tasks render above the thread rows (mirrors the + // compact Home v2 list): they are not thread shells, so the v2 item + // builder never sees them, but they must stay visible and deletable + // while their environment is offline. Same environment scope and + // search filter as the list. + const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2PendingTasks = pendingTasks.filter( + (pendingTask) => + (options.selectedEnvironmentId === null || + pendingTask.message.environmentId === options.selectedEnvironmentId) && + (selectedProject === null || + (pendingTask.message.environmentId === selectedProject.environmentId && + pendingTask.creation.projectId === selectedProject.id)) && + (v2SearchQuery.length === 0 || + pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ); + const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ + type: "v2-pending-task" as const, + key: `v2-pending:${pendingTask.message.messageId}`, + pendingTask, + isLast: index === v2PendingTasks.length - 1, + })); + for (const item of threadListV2Layout.items) { + items.push({ + type: "v2-thread" as const, + key: scopedThreadKey(item.thread.environmentId, item.thread.id), + item, + }); + } + if (threadListV2Layout.hiddenSettledCount > 0) { + items.push({ + type: "v2-show-more", + key: "v2-show-more", + hiddenCount: threadListV2Layout.hiddenSettledCount, + }); + } + return items; + }, [ + listLayout.items, + options.selectedEnvironmentId, + pendingTasks, + props.searchQuery, + selectedProject, + threadListV2Enabled, + threadListV2Layout, + ]); const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ @@ -260,36 +496,64 @@ function ThreadNavigationSidebarPane( })), ], }, - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: options.projectSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: options.threadSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "project-grouping", - title: "Group projects", - subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ - id: `project-grouping:${option.value}`, - title: option.label, - subtitle: option.subtitle, - state: options.projectGroupingMode === option.value ? "on" : "off", - })), - }, + ...(projectFilterOptions.length === 0 + ? [] + : ([ + { + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + subtitle: "Show threads from every project", + state: selectedProjectKey === null ? "on" : "off", + }, + ...projectFilterOptions.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: selectedProjectKey === project.key ? ("on" as const) : ("off" as const), + })), + ], + }, + ] satisfies MenuAction[])), + // v2 lays the list out in fixed creation order — offering sort/group + // controls it silently ignores would be a lie. Environment still + // scopes the v2 partition, so it stays. + ...(threadListV2Enabled + ? [] + : ([ + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: options.projectSortOrder === option.value ? "on" : "off", + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: options.threadSortOrder === option.value ? "on" : "off", + })), + }, + { + id: "project-grouping", + title: "Group projects", + subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ + id: `project-grouping:${option.value}`, + title: option.label, + subtitle: option.subtitle, + state: options.projectGroupingMode === option.value ? "on" : "off", + })), + }, + ] satisfies MenuAction[])), ], - [environments, options], + [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -305,6 +569,17 @@ function ThreadNavigationSidebarPane( if (environment) setSelectedEnvironmentId(environment.environmentId); return; } + if (event === "project:all") { + setSelectedProjectKey(null); + return; + } + if (event.startsWith("project:")) { + const projectKey = event.slice("project:".length); + if (projectFilterOptions.some((project) => project.key === projectKey)) { + setSelectedProjectKey(projectKey); + } + return; + } const projectSort = PROJECT_SORT_OPTIONS.find( (option) => `project-sort:${option.value}` === event, ); @@ -326,6 +601,7 @@ function ThreadNavigationSidebarPane( }, [ environments, + projectFilterOptions, setProjectGroupingMode, setProjectSortOrder, setSelectedEnvironmentId, @@ -382,7 +658,44 @@ function ThreadNavigationSidebarPane( onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); - const listExtraData = props.selectedThreadKey ?? ""; + const listExtraData = useMemo( + () => ({ + selectedThreadKey: props.selectedThreadKey ?? "", + savedConnectionsById, + serverConfigs, + }), + [props.selectedThreadKey, savedConnectionsById, serverConfigs], + ); + const sidebarItemsAreEqual = useCallback( + (previous: SidebarListItem, item: SidebarListItem): boolean => { + if (previous.type === "v2-thread" && item.type === "v2-thread") { + return ( + previous.key === item.key && + previous.item.thread === item.item.thread && + previous.item.variant === item.item.variant && + previous.item.showSettledDivider === item.item.showSettledDivider + ); + } + if (previous.type === "v2-show-more" && item.type === "v2-show-more") { + return previous.hiddenCount === item.hiddenCount; + } + if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") { + return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast; + } + if ( + previous.type === "v2-thread" || + previous.type === "v2-show-more" || + previous.type === "v2-pending-task" || + item.type === "v2-thread" || + item.type === "v2-show-more" || + item.type === "v2-pending-task" + ) { + return false; + } + return homeListItemsAreEqual(previous, item); + }, + [], + ); const focusSearch = useCallback(() => { const focus = () => { if (props.nativeChrome) { @@ -401,8 +714,78 @@ function ThreadNavigationSidebarPane( }, [props.nativeChrome, props.onRequestVisibility, props.visible]); useHardwareKeyboardCommand("focusSearch", focusSearch); const renderListItem = useCallback( - ({ item }: { readonly item: HomeListItem }) => { + ({ item }: { readonly item: SidebarListItem }) => { switch (item.type) { + case "v2-pending-task": + return ( + + ); + case "v2-thread": { + const thread = item.item.thread; + const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); + return ( + + provider.instanceId === + (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), + )?.driver ?? null + } + environmentLabel={ + Object.keys(savedConnectionsById).length > 1 + ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) + : null + } + pane="sidebar" + selected={ + scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey + } + fullSwipeWidth={props.width - 20} + onSelectThread={handleSelectThread} + onDeleteThread={confirmDeleteThread} + onArchiveThread={archiveThread} + settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} + onSettleThread={settleThread} + onUnsettleThread={unsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={projectCwdByKey.get(scopeKey) ?? null} + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + simultaneousSwipeGesture={sidebarScrollGesture} + /> + ); + } + case "v2-show-more": + return ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({item.hiddenCount} settled hidden) + + + ); case "header": return ( buildHomeListFilterMenu({ environments, + projects: projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, + selectedProjectKey, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, onEnvironmentChange: setSelectedEnvironmentId, + onProjectChange: setSelectedProjectKey, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, onProjectGroupingModeChange: setProjectGroupingMode, + listOrganization: !threadListV2Enabled, }), [ environments, options, + projectFilterOptions, + selectedProjectKey, setProjectGroupingMode, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, + threadListV2Enabled, ], ); const nativeHeaderItems = useMemo( @@ -531,7 +933,9 @@ function ThreadNavigationSidebarPane( ? "Loading threads…" : props.searchQuery.trim().length > 0 ? "No matching threads" - : "No threads yet"} + : selectedProject !== null + ? `No threads in ${selectedProject.title}` + : "No threads yet"} ); @@ -539,6 +943,7 @@ function ThreadNavigationSidebarPane( return ( <> item.type} - itemsAreEqual={homeListItemsAreEqual} + itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} @@ -625,12 +1030,12 @@ function ThreadNavigationSidebarPane( item.type} - itemsAreEqual={homeListItemsAreEqual} + itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} contentContainerStyle={[ diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 9dd41f3cd60..63eb9a4d9f3 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -19,8 +19,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; /** - * Thread List v2 rows mirror the web sidebar's compact tonal cards and - * receded settled tail while retaining native swipe and long-press actions. + * Thread List v2 renders one flat native list: rich edge-to-edge rows for + * active work and a receded settled tail, all with native swipe and + * long-press actions. State reads through colored status labels and text + * hierarchy rather than card fills. */ const MONO_FONT = Platform.select({ @@ -29,12 +31,15 @@ const MONO_FONT = Platform.select({ default: "monospace", }); +// Status hues follow the system-wide convention set by sidebar v1 and the +// Live Activity/widgets (amber approval, indigo input, sky working) so a +// thread reads the same color everywhere it surfaces. const STATUS_LABEL_BY_STATUS: Partial< Record > = { approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, - input: { label: "Input", className: "text-amber-700 dark:text-amber-300" }, - working: { label: "Working", className: "text-blue-600 dark:text-blue-400" }, + input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" }, + working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, }; @@ -60,10 +65,20 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; -export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() { +/** Rounded-row radius shared with the v1 sidebar rows. */ +const SIDEBAR_V2_ROW_RADIUS = 12; + +export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider(props: { + readonly pane?: "screen" | "sidebar"; +}) { const borderColor = useThemeColor("--color-border"); return ( - + Settled @@ -76,6 +91,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly showSettledDivider: boolean; readonly project: EnvironmentProject | null; readonly providerDriver: string | null; + /** Which machine hosts the thread. Null when only one environment is + connected — repeating the same label on every row is noise. Mirrors + the web sidebar's remote-environment cloud icon, but as text since + phones have no hover tooltips. */ + readonly environmentLabel: string | null; + /** Hosting surface. "screen" (default) renders the compact Home idiom: + flat edge-to-edge rows on the screen background with inset hairlines. + "sidebar" renders the iPad split-view idiom: rounded rows blending + into the drawer surface, selection filled with the accent color — + matching the v1 sidebar rows. */ + readonly pane?: "screen" | "sidebar"; + /** Highlights the thread open in the detail pane (iPad split view). The + compact Home list never sets it — phones navigate away on select. */ + readonly selected?: boolean; + /** Override for narrow panes (iPad sidebar); defaults to window width. */ + readonly fullSwipeWidth?: number; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => void; @@ -117,6 +148,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, [onChangeRequestState, prState, threadKey]); const screenColor = useThemeColor("--color-screen"); + const drawerColor = useThemeColor("--color-drawer"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const selectedBackgroundColor = useThemeColor("--color-user-bubble"); + const sidebarPane = props.pane === "sidebar"; + const selected = props.selected === true; const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; @@ -174,99 +210,184 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { thread.title, ]); + // The sidebar pane fills selected rows with the accent color (matching the + // v1 sidebar), so every piece of row text needs a white-on-accent variant. + const cardContent = ( + <> + + {props.project ? ( + + ) : null} + + {props.project?.title ?? ""} + + + {statusLabel?.label ?? timeLabel} + + + + {thread.title} + + + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : thread.branch || props.environmentLabel ? ( + /* "branch · machine" share one truncating line. The machine sits + last so a tight fit cuts the repetitive label, not the branch — + and machine-only fills the row for non-git projects. */ + + {thread.branch ? ( + + {thread.branch} + + ) : null} + {thread.branch && props.environmentLabel ? " · " : null} + {props.environmentLabel ? ( + + {props.environmentLabel} + + ) : null} + + ) : ( + + )} + {pr ? ( + + #{pr.label} + + ) : null} + {props.providerDriver ? ( + + + + ) : null} + + + ); + const rowContent = (close: () => void) => variant === "card" ? ( { close(); onSelectThread(thread); }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + style={ + sidebarPane + ? ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed + ? pressedBackgroundColor + : drawerColor, + borderRadius: SIDEBAR_V2_ROW_RADIUS, + paddingHorizontal: 12, + paddingVertical: 10, + }) + : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + } > - - - - - {props.project ? ( - - ) : null} - - {props.project?.title ?? ""} - - - {statusLabel?.label ?? timeLabel} - - - - {thread.title} - - - {status === "failed" && thread.session?.lastError ? ( - - {thread.session.lastError} - - ) : thread.branch ? ( - - {thread.branch} - - ) : ( - - )} - {props.providerDriver ? ( - - - - ) : null} - - + {sidebarPane ? ( + cardContent + ) : ( + /* Flat native list rows: no tonal containers — colored status + labels and text hierarchy carry state, an inset hairline + separates rows. The opaque screen background stays so swipe + actions reveal behind the row. */ + + {cardContent} + - + )} ) : ( { close(); onSelectThread(thread); }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + style={ + sidebarPane + ? ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed + ? pressedBackgroundColor + : drawerColor, + borderRadius: SIDEBAR_V2_ROW_RADIUS, + }) + : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + } > {/* Settled history recedes: dimmed favicon + muted title. */} - + {props.project ? ( ) : null} - + {thread.title} {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} @@ -292,14 +422,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { return ( <> - {props.showSettledDivider ? : null} + {props.showSettledDivider ? : null} , ): ThreadListV2Status { diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 8a8c355b760..78c87119512 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -142,6 +142,13 @@ function stabilizeOptionFunctions( export function NativeStackScreenOptions(props: { readonly options?: AppNativeStackNavigationOptions; + /** + * Causes dynamic native header factories to be reapplied when their closed-over + * menu content changes. Factory functions are intentionally stabilized, so + * their source alone cannot capture a menu that was initially empty while + * asynchronous data was loading. + */ + readonly optionsVersion?: unknown; readonly listeners?: Record void>; readonly name?: string; }) { @@ -163,7 +170,7 @@ export function NativeStackScreenOptions(props: { if (!navigation || !stableOptions) { return; } - const signature = optionsSignature(stableOptions); + const signature = optionsSignature([stableOptions, props.optionsVersion]); // Avoid re-entering navigation state when semantically equal options are // reapplied every layout (common when callers pass unstable object literals). if (lastAppliedOptionsSignatureRef.current === signature) { @@ -171,7 +178,7 @@ export function NativeStackScreenOptions(props: { } lastAppliedOptionsSignatureRef.current = signature; navigation.setOptions(stableOptions); - }, [navigation, stableOptions]); + }, [navigation, props.optionsVersion, stableOptions]); useEffect(() => { if (!navigation || !props.listeners) { diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 8a713fd9026..ef6b64be9c9 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -84,7 +84,7 @@ function SidebarControl() { "pointer-events-auto", isSidebarVisible && stageBackdropVariant && - "hover:bg-white/15 [&_svg]:text-white/85! [&_svg]:hover:text-white!", + "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!", )} aria-label="Toggle main sidebar" /> @@ -164,10 +164,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {

      { + it("assigns positional jump shortcuts to the first nine displayed items", () => { + const items = Array.from({ length: 10 }, (_, index) => ({ + kind: "action" as const, + value: `project-${index + 1}`, + searchTerms: [], + title: `Project ${index + 1}`, + icon: null, + shortcutCommand: "chat.new" as const, + run: async () => undefined, + })); + + expect(enumerateCommandPaletteItems(items).map((item) => item.shortcutCommand)).toEqual([ + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9", + undefined, + ]); + }); +}); + const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ab53adbefb1..a217d53b5b7 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,8 @@ -import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts"; +import { + type KeybindingCommand, + type FilesystemBrowseEntry, + THREAD_JUMP_KEYBINDING_COMMANDS, +} from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; @@ -52,6 +56,18 @@ export interface CommandPaletteView { readonly initialQuery?: string; } +export function enumerateCommandPaletteItems( + items: ReadonlyArray, +): CommandPaletteActionItem[] { + return items.map((item, index) => { + const shortcutCommand = THREAD_JUMP_KEYBINDING_COMMANDS[index]; + if (shortcutCommand) return { ...item, shortcutCommand }; + + const { shortcutCommand: _shortcutCommand, ...itemWithoutShortcut } = item; + return itemWithoutShortcut; + }); +} + export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; export function filterBrowseEntries(input: { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8c7e6d287ca..bb83dfa5691 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -95,6 +95,7 @@ import { buildProjectActionItems, buildRootGroups, buildThreadActionItems, + enumerateCommandPaletteItems, type CommandPaletteActionItem, type CommandPaletteSubmenuItem, type CommandPaletteView, @@ -112,7 +113,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; -import { resolveShortcutCommand } from "../keybindings"; +import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { Command, CommandDialog, @@ -686,29 +687,30 @@ function OpenCommandPaletteDialog(props: { const projectThreadItems = useMemo( () => - buildProjectActionItems({ - projects, - valuePrefix: "new-thread-in", - shortcutCommand: "chat.new", - icon: (project) => ( - - ), - runProject: async (project) => { - await startNewThreadInProjectFromContext( - { - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }, - scopeProjectRef(project.environmentId, project.id), - ); - }, - }), + enumerateCommandPaletteItems( + buildProjectActionItems({ + projects, + valuePrefix: "new-thread-in", + icon: (project) => ( + + ), + runProject: async (project) => { + await startNewThreadInProjectFromContext( + { + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + }), + ), [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], ); @@ -997,7 +999,13 @@ function OpenCommandPaletteDialog(props: { : projectThreadItems; pushPaletteView({ addonIcon: , - groups: [{ value: "projects", label: "Projects", items: prioritized }], + groups: [ + { + value: "projects", + label: "Projects", + items: enumerateCommandPaletteItems(prioritized), + }, + ], }); }, [ clearOpenIntent, @@ -1545,6 +1553,22 @@ function OpenCommandPaletteDialog(props: { } function handleKeyDown(event: KeyboardEvent): void { + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { modelPickerOpen: false }, + }); + if (threadJumpIndexFromCommand(command ?? "") !== null) { + const matchingItem = displayedGroups + .flatMap((group) => group.items) + .find((item) => item.shortcutCommand === command); + if (matchingItem) { + event.preventDefault(); + event.stopPropagation(); + executeItem(matchingItem); + return; + } + } + if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { event.preventDefault(); void submitAddProjectCloneFlow(); @@ -1855,7 +1879,7 @@ function OpenCommandPaletteDialog(props: { {remoteProjectContext.title} - + {remoteProjectContext.description} @@ -1896,37 +1920,35 @@ function OpenCommandPaletteDialog(props: { - Navigate + Navigate {addProjectCloneFlow?.step === "repository" ? ( Enter - - {remoteProjectButtonLabel ?? "Continue"} - + {remoteProjectButtonLabel ?? "Continue"} ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( Enter - Select + Select ) : null} {isSubmenu ? ( Backspace - Back + Back ) : null} Esc - Close + Close
      {canOpenProjectFromFileManager ? ( @@ -166,7 +166,7 @@ function RightPanelEmptyState(props: { const disabledCard = ( + ) : ( + + )} - {!props.settlementSupported ? null : variantAction === "unsettle" ? ( - - ) : ( - - )} - -
      + + {detailsTooltip} +
    • ); } @@ -460,144 +605,109 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { data-thread-item className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]" > -
      -
      -
      - + - {props.projectTitle ? ( - - {props.projectTitle} - - ) : ( - - )} - - - {props.jumpLabel ? ( - props.jumpLabel - ) : topStatus ? ( - +
      +
      + + {props.projectTitle ? ( + + {props.projectTitle} + + ) : ( + + )} + + + {props.jumpLabel ? ( + props.jumpLabel + ) : topStatus ? ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {topStatus.label} + + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported ? ( + + ) : null} - {props.settlementSupported ? ( - +
      +
      {title}
      +
      + {thread.branch ? ( + {thread.branch} + ) : ( + + )} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + ) : null} - -
      -
      {title}
      -
      - {thread.branch ? ( - - {thread.branch} - - ) : ( - - )} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {driverKind ? ( - - } + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + - - {thread.modelSelection.model} - - ) : null} - {isRemote ? ( - - - } - > - - - - Running on {props.environmentLabel ?? "a remote environment"} - - - ) : null} - -
      - {status === "failed" && thread.session?.lastError ? ( -
      - {thread.session.lastError} + + ) : null} +
      - ) : null} -
      -
      +
      + + {detailsTooltip} + ); }); @@ -711,9 +821,8 @@ export default function SidebarV2() { [], ); - // Project scope: chips above the list. Scoping filters the list AND - // becomes the new-thread target — one visible control doing both jobs the - // old per-project headers did. + // Project scope: one menu above the list. Scoping filters the list without + // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); const scopedProject = useMemo( () => @@ -794,7 +903,7 @@ export default function SidebarV2() { // filter context changes so a scope/search flip never inherits a deep // page state. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const settledResetKey = `${projectScopeKey ?? "all"}`; + const settledResetKey = projectScopeKey ?? "all"; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -1322,243 +1431,245 @@ export default function SidebarV2() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal") ?? shortcutLabelForCommand(keybindings, "chat.new"); - const projectScrollerRef = useRef(null); - const [canScrollProjectsRight, setCanScrollProjectsRight] = useState(false); - const updateProjectScrollFade = useCallback(() => { - const scroller = projectScrollerRef.current; - if (!scroller) return; - setCanScrollProjectsRight( - scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, - ); - }, []); - useEffect(() => { - const scroller = projectScrollerRef.current; - if (!scroller) return; - - updateProjectScrollFade(); - const resizeObserver = new ResizeObserver(updateProjectScrollFade); - resizeObserver.observe(scroller); - return () => resizeObserver.disconnect(); - }, [projects, updateProjectScrollFade]); - return ( <> - - +
      +
      } > - - Search + +
      Search
      {commandPaletteShortcutLabel ? ( - + {commandPaletteShortcutLabel} ) : null}
      - - - - - - - +
      +
      + + + } + > + + + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
      +
      {projects.length > 0 ? ( - -
      -
      - {projects.length > 1 ? ( - - ) : null} - {projects.map((project) => { - const scopeKey = `${project.environmentId}:${project.id}`; - const isScoped = projectScopeKey === scopeKey; - return ( - - ); - })} -
      -
      - - + +
      + + + {scopedProject ? ( + + ) : ( + + )} + + {scopedProject?.title ?? "All projects"} + + + + + + setProjectScopeKey(value === "all" ? null : (value as string)) } > - - - Add project - -
      + + + All projects + + {projects.map((project) => { + const scopeKey = `${project.environmentId}:${project.id}`; + return ( + + + {project.title} + + ); + })} + + + + + + } + > + + + New project +
      ) : null} -
        - {orderedThreads.flatMap((thread, threadIndex) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const isSettledRow = settledThreadKeys.has(threadKey); - // Settled is the ONLY thing that collapses a row: every - // not-settled thread is a full card. Density comes from users - // (or the auto rules) actually settling work, not from the - // sidebar second-guessing what still matters. - const isCard = !isSettledRow; - const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; - const previousWasCard = - previousThread != null && - !settledThreadKeys.has( - scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), + +
          + {orderedThreads.flatMap((thread, threadIndex) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isSettledRow = settledThreadKeys.has(threadKey); + // Settled is the ONLY thing that collapses a row: every + // not-settled thread is a full card. Density comes from users + // (or the auto rules) actually settling work, not from the + // sidebar second-guessing what still matters. + const isCard = !isSettledRow; + const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; + const previousWasCard = + previousThread != null && + !settledThreadKeys.has( + scopedThreadKey( + scopeThreadRef(previousThread.environmentId, previousThread.id), + ), + ); + const showSettledGap = !isCard && previousWasCard; + const row = ( + ); - const showSettledGap = !isCard && previousWasCard; - const row = ( - - ); - if (!showSettledGap) return [row]; - // The divider is its own keyed list item (not part of the first - // settled row): it keeps one stable DOM node at the boundary, - // so settling a thread slides it instead of teleporting it - // along with whichever row happens to be first in the tail — - // and row heights stay independent of neighbor classification. - return [ -
        • -
          - - Settled + if (!showSettledGap) return [row]; + // The divider is its own keyed list item (not part of the first + // settled row): it keeps one stable DOM node at the boundary, + // so settling a thread slides it instead of teleporting it + // along with whichever row happens to be first in the tail — + // and row heights stay independent of neighbor classification. + return [ +
        • +
          + Settled + +
          +
        • , + row, + ]; + })} + {hiddenSettledCount > 0 ? ( +
        • +
      - , - row, - ]; - })} - {hiddenSettledCount > 0 ? ( -
    • - -
    • - ) : null} -
    + + + ) : null} + + {orderedThreads.length === 0 ? (
    {projects.length === 0 ? ( @@ -1567,7 +1678,7 @@ export default function SidebarV2() { + + + ), + }, + ]; + }, [ + activeThread?.id, + branchRepairAction, + handleSwitchCheckoutToThread, + handleUpdateThreadToCheckout, + localCheckoutBranchMismatch, + systemComposerBannerItems, + ]); useEffect(() => { setPendingServerThreadEnvMode(null); @@ -4313,6 +4493,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode, }); @@ -4694,6 +4877,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, modelSelection: ctxSelectedModelSelection, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode: nextInteractionMode, }); @@ -4770,6 +4956,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting, isSendBusy, isServerThread, + localCheckoutBranchMismatch, persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f71e855a18f..d4cf4002a2c 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1117,12 +1117,12 @@ export default function GitActionsControl({ activeDraftThread.worktreePath === null; useEffect(() => { - if (isGitActionRunning || isSelectingWorktreeBase) { + if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, + threadBranch: activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); if (!branchUpdate) { @@ -1131,7 +1131,7 @@ export default function GitActionsControl({ persistThreadBranchSync(branchUpdate.branch); }, [ - activeServerThread?.branch, + activeServerThread, activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fabedfc80c7..b9bdc1c043b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { import { ChangeRequestStatusIcon, prStatusIndicator, + PrStatusTooltipContent, resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, @@ -460,7 +461,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr lastVisitedAt, }, }); - const pr = resolveThreadPr(thread.branch, gitStatus.data); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; @@ -700,7 +705,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr } /> - {prStatus.tooltip} + + + )} {threadStatus && } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 9bc87eed64a..2b81c15c7ad 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -83,6 +83,7 @@ import { resolveSidebarV2Status, sortThreadsForSidebarV2, } from "./Sidebar.logic"; +import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; @@ -127,6 +128,7 @@ function SidebarV2ThreadTooltip({ modelInstanceId, modelLabel, status, + branchMismatch, }: { thread: SidebarThreadSummary; projectTitle: string | null; @@ -140,6 +142,10 @@ function SidebarV2ThreadTooltip({ className: string; icon: "working" | "done" | null; } | null; + branchMismatch: { + threadBranch: string; + currentBranch: string; + } | null; }) { return (
    ) : null} + {branchMismatch ? ( +
    + +
    + You're currently checked out on another branch. +
    +
    + ) : null} {driverKind ? (
    ); diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts new file mode 100644 index 00000000000..24ccc6ef33f --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -0,0 +1,73 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; + +function status(overrides: Partial = {}): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/current", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: { + number: 42, + title: "PR branch", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature/current", + state: "open", + }, + ...overrides, + }; +} + +describe("resolveThreadPr", () => { + it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { + expect( + resolveThreadPr({ + threadBranch: "feature/other", + gitStatus: status(), + hasDedicatedWorktree: false, + }), + ).toBeNull(); + }); + + it("shows PR indicators for dedicated worktree threads even when branch metadata is stale", () => { + const gitStatus = status(); + + expect( + resolveThreadPr({ + threadBranch: "feature/old-name", + gitStatus, + hasDedicatedWorktree: true, + }), + ).toBe(gitStatus.pr); + }); + + it("shows PR indicators for dedicated worktree threads even when branch metadata is missing", () => { + const gitStatus = status(); + + expect( + resolveThreadPr({ + threadBranch: null, + gitStatus, + hasDedicatedWorktree: true, + }), + ).toBe(gitStatus.pr); + }); +}); + +describe("prStatusIndicator", () => { + it("formats PR tooltips with number, uppercase status, and title", () => { + expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ + tooltip: "PR #42 - Open: PR branch", + tooltipLead: "PR #42 - Open", + tooltipTitle: "PR branch", + }); + }); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 55f9fbfdc04..3c58d6ea989 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -22,6 +22,8 @@ export interface PrStatusIndicator { label: string; colorClass: string; tooltip: string; + tooltipLead: string; + tooltipTitle: string; url: string; } @@ -37,14 +39,26 @@ export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { + function formatPrState(state: NonNullable["state"]): string { + return state.charAt(0).toUpperCase() + state.slice(1); + } + + function formatPrStatusLead(pr: NonNullable, changeRequestShortName: string): string { + return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; + } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); + const tooltipLead = formatPrStatusLead(pr, presentation.shortName); + const tooltip = `${tooltipLead}: ${pr.title}`; + if (pr.state === "open") { return { label: `${presentation.shortName} open`, colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -52,7 +66,9 @@ export function prStatusIndicator( return { label: `${presentation.shortName} closed`, colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -60,7 +76,9 @@ export function prStatusIndicator( return { label: `${presentation.shortName} merged`, colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -71,11 +89,31 @@ export function ChangeRequestStatusIcon({ className }: { className?: string }) { return ; } -export function resolveThreadPr( - threadBranch: string | null, - gitStatus: VcsStatusResult | null, -): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { +export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { + return ( + + {status.tooltipLead} + + ); +} + +export function resolveThreadPr(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + hasDedicatedWorktree: boolean; +}): ThreadPr | null { + const { threadBranch, gitStatus, hasDedicatedWorktree } = input; + if (gitStatus === null) { + return null; + } + + if (hasDedicatedWorktree) { + return gitStatus.pr ?? null; + } + + if (threadBranch === null || gitStatus.refName !== threadBranch) { return null; } @@ -199,14 +237,18 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd; const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, }) : null, ); - const pr = resolveThreadPr(thread.branch, gitStatus.data); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ thread: { @@ -233,7 +275,9 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar > - {prStatus.tooltip} + + + ) : null} {threadStatus ? : null} diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index 16fdff64425..520f130416a 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -34,4 +34,22 @@ describe("ComposerBannerStack", () => { expect(markup).not.toContain("data-composer-banner-stack-expanded-items"); }); + + it("applies item-specific surface and action layout classes", () => { + const markup = renderToStaticMarkup( + Repair, + }, + ]} + />, + ); + + expect(markup).toContain("branch-surface"); + expect(markup).toContain("branch-actions"); + }); }); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index d51c23a2f27..0b9acbcc997 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -30,6 +30,8 @@ export interface ComposerBannerStackItem { readonly title: ReactNode; readonly description?: ReactNode; readonly actions?: ReactNode; + readonly className?: string; + readonly actionClassName?: string; readonly dismissLabel?: string; readonly onDismiss?: () => void; } @@ -171,17 +173,18 @@ function ComposerBannerStackAlert({ const dismissOnly = item.onDismiss && !item.actions; return ( - + {item.icon} {item.title} {item.description ? {item.description} : null} {item.actions || item.onDismiss ? ( {item.actions} {item.onDismiss ? ( From c5ff51ec1fff1a9f0447b0336d645ab0f089db62 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 22 Jul 2026 19:08:00 -0400 Subject: [PATCH 057/110] feat(web): refresh application surfaces --- apps/web/src/components/AppSidebarLayout.tsx | 3 +- apps/web/src/components/BranchToolbar.tsx | 2 +- apps/web/src/components/ChatView.tsx | 34 +-- apps/web/src/components/CommandPalette.tsx | 2 +- apps/web/src/components/DiffPanel.tsx | 36 ++- apps/web/src/components/GitActionsControl.tsx | 18 +- .../src/components/ProjectScriptsControl.tsx | 8 +- apps/web/src/components/Sidebar.logic.test.ts | 22 +- apps/web/src/components/Sidebar.logic.ts | 13 +- apps/web/src/components/Sidebar.tsx | 26 +- .../components/SidebarStageBackdrop.test.tsx | 22 ++ .../src/components/SidebarStageBackdrop.tsx | 110 ++++---- apps/web/src/components/SidebarV2.tsx | 188 ++++++++----- .../src/components/chat/ChangedFilesTree.tsx | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 14 +- .../chat/ComposerPrimaryActions.tsx | 14 +- .../components/chat/ContextWindowMeter.tsx | 17 +- .../components/chat/MessagesTimeline.test.tsx | 17 ++ .../src/components/chat/MessagesTimeline.tsx | 16 +- apps/web/src/components/chat/ModelListRow.tsx | 5 +- .../components/chat/ModelPickerContent.tsx | 11 +- .../components/chat/ModelPickerSidebar.tsx | 7 +- .../components/chat/ProviderStatusBanner.tsx | 2 +- apps/web/src/components/chat/TraitsPicker.tsx | 8 +- .../settings/ConnectionsSettings.tsx | 9 +- .../settings/ProviderInstanceCard.tsx | 18 +- .../settings/ProviderModelsSection.tsx | 2 +- .../settings/ProviderSettingsForm.tsx | 2 +- .../settings/SettingsSidebarNav.tsx | 11 +- .../settings/SourceControlSettings.tsx | 12 +- apps/web/src/components/settings/itemRows.ts | 4 +- .../components/settings/settingsLayout.tsx | 35 +-- .../src/components/sidebar/SidebarChrome.tsx | 4 +- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/combobox.tsx | 14 +- apps/web/src/components/ui/command.tsx | 17 +- apps/web/src/components/ui/menu.tsx | 26 +- apps/web/src/components/ui/select.tsx | 29 +- apps/web/src/index.css | 258 +++++++++++++----- apps/web/src/routes/settings.tsx | 6 +- 40 files changed, 605 insertions(+), 441 deletions(-) create mode 100644 apps/web/src/components/SidebarStageBackdrop.test.tsx diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index ef6b64be9c9..3a70390d0c4 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -106,6 +106,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const pathname = useLocation({ select: (location) => location.pathname }); const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); const useSidebarV2 = sidebarV2Enabled && !isOnSettings; + const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); @@ -165,7 +166,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" - data-sidebar-version={useSidebarV2 ? "v2" : "v1"} + data-sidebar-version={useSidebarV2Theme ? "v2" : "v1"} className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" resizable={{ maxWidth: sidebarMaximumWidth, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 0354c2e0cd7..62c5e20da9f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -248,7 +248,7 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
    +
    {isMobile ? ( status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const hasTimelineTopBanner = + Boolean(threadError) || + (activeProviderStatus !== null && + activeProviderStatus.status !== "ready" && + activeProviderStatus.status !== "disabled"); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -5412,7 +5417,7 @@ function ChatViewContent(props: ChatViewProps) {
    - {/* Error banner */} - setThreadError(activeThread.id, null)} @@ -5458,6 +5461,10 @@ function ChatViewContent(props: ChatViewProps) {
    {/* Chat column */}
    + {/* Provider status overlays the timeline without changing its content height. */} +
    + +
    {/* Messages Wrapper */}
    {/* Messages — LegendList handles virtualization and scrolling internally */} @@ -5494,6 +5501,7 @@ function ChatViewContent(props: ChatViewProps) { onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} + topFadeEnabled={!hasTimelineTopBanner} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5526,17 +5534,6 @@ function ChatViewContent(props: ChatViewProps) { : "pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2" } > - {!isDraftHeroState ? ( -