From 2cbc24fcae2b5649d7b60b68da72053a37fa82d5 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:15:11 +0000 Subject: [PATCH 01/28] chore(release): prepare v0.0.43 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index daedb89a1283..088282c83859 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.42", + "version": "0.0.43", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 767c1b0c3393..e459781fa8e6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.42", + "version": "0.0.43", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 665cdbf71736..3f23a2e32b8a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.42", + "version": "0.0.43", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index e65ac671b0a9..d356c57c2375 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.42", + "version": "0.0.43", "private": true, "files": [ "dist" From 451afcb22d93f06cb24f9bc16703404564952553 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 29 Sep 2026 12:51:36 -0700 Subject: [PATCH 02/28] fix(codex): Pro Max accounts load, so Ultrafast shows up (#14304) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/provider/Layers/CodexProvider.test.ts | 10 + .../src/provider/Layers/CodexProvider.ts | 22 +- .../scripts/generate.ts | 9 +- .../src/_generated/schema.gen.ts | 205 ++---------------- .../src/schema.test.ts | 4 +- 5 files changed, 57 insertions(+), 193 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0c7a40d9bd9e..2c67b6f144db 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -81,6 +81,11 @@ it("uses standard routing when the catalog has no default service tier", () => { name: "Fast", description: "1.5x speed, increased usage", }, + { + id: "ultrafast", + name: "Ultrafast", + description: "The fastest available responses for latency-sensitive work.", + }, ], supportedReasoningEfforts: [], }); @@ -97,6 +102,11 @@ it("uses standard routing when the catalog has no default service tier", () => { label: "Fast", description: "1.5x speed, increased usage", }, + { + id: "ultrafast", + label: "Ultrafast", + description: "Even faster, more expensive", + }, ], currentValue: "default", }, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 57b0cab334bb..72ca3fd74f0d 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -90,6 +90,11 @@ const REASONING_EFFORT_LABELS: Readonly> = { const DEFAULT_SERVICE_TIER_ID = "default"; +/** Shorter copy for tiers whose catalog description wraps in the traits menu. */ +const SERVICE_TIER_DESCRIPTIONS: Readonly> = { + ultrafast: "Even faster, more expensive", +}; + function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } @@ -115,6 +120,8 @@ export function codexPlanLabel(planType: string | null | undefined): string | un return "ChatGPT Pro 20x Subscription"; case "prolite": return "ChatGPT Pro 5x Subscription"; + case "promax": + return "ChatGPT Pro Max Subscription"; case "team": return "ChatGPT Team Subscription"; case "self_serve_business_prolite": @@ -195,12 +202,15 @@ export function mapCodexModelCapabilities( label: "Standard", ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), }, - ...serviceTiers.map((tier) => ({ - id: tier.id, - label: tier.name, - ...(tier.description ? { description: tier.description } : {}), - ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), - })), + ...serviceTiers.map((tier) => { + const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description; + return { + id: tier.id, + label: tier.name, + ...(description ? { description } : {}), + ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), + }; + }), ], currentValue: defaultServiceTier, }); diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index b2696097ef8c..8825f6044fc2 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -145,6 +145,13 @@ const ManualSchemas: Record = { }, }; +// Codex adds plan slugs between our protocol refreshes (0.159 added `promax`). +// T3 Code only uses the plan for labels, so an unknown slug must not fail the +// whole `account/read` decode and take the provider down with it. +const DefinitionOverrides: Record = { + PlanType: { type: "string" }, +}; + const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () { const path = yield* Path.Path; const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated"); @@ -643,7 +650,7 @@ const generateFiles = Effect.fn("generateFiles")(function* () { aggregateSchemas[localDefinitionNames.get(definitionName)!] = stripNullDefaults( normalizeNullableTypes( rewriteExternalRefs( - definitionSchema, + DefinitionOverrides[definitionName] ?? definitionSchema, localDefinitionNames, file.namespace, exportNameByQualifiedName, 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 9bc7b1ec1e9e..e9ac22ec120b 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -3204,43 +3204,10 @@ export const ServerNotification__AuthMode = Schema.Union( identifier: "ServerNotification__AuthMode", }); -export type ServerNotification__PlanType = - | "free" - | "go" - | "plus" - | "pro" - | "prolite" - | "team" - | "self_serve_business_prolite" - | "self_serve_business_usage_based" - | "business" - | "ent26" - | "enterprise_cbp_automation" - | "enterprise_cbp_usage_based" - | "enterprise" - | "edu" - | "edu_plus" - | "edu_pro" - | "unknown"; -export const ServerNotification__PlanType = Schema.Literals([ - "free", - "go", - "plus", - "pro", - "prolite", - "team", - "self_serve_business_prolite", - "self_serve_business_usage_based", - "business", - "ent26", - "enterprise_cbp_automation", - "enterprise_cbp_usage_based", - "enterprise", - "edu", - "edu_plus", - "edu_pro", - "unknown", -]).annotate({ identifier: "ServerNotification__PlanType" }); +export type ServerNotification__PlanType = string; +export const ServerNotification__PlanType = Schema.String.annotate({ + identifier: "ServerNotification__PlanType", +}); export type ServerNotification__CreditsSnapshot = { readonly balance?: string | null; @@ -4138,43 +4105,10 @@ export const V2AccountRateLimitsUpdatedNotification__SpendControlLimitSnapshot = used: Schema.String, }).annotate({ identifier: "V2AccountRateLimitsUpdatedNotification__SpendControlLimitSnapshot" }); -export type V2AccountRateLimitsUpdatedNotification__PlanType = - | "free" - | "go" - | "plus" - | "pro" - | "prolite" - | "team" - | "self_serve_business_prolite" - | "self_serve_business_usage_based" - | "business" - | "ent26" - | "enterprise_cbp_automation" - | "enterprise_cbp_usage_based" - | "enterprise" - | "edu" - | "edu_plus" - | "edu_pro" - | "unknown"; -export const V2AccountRateLimitsUpdatedNotification__PlanType = Schema.Literals([ - "free", - "go", - "plus", - "pro", - "prolite", - "team", - "self_serve_business_prolite", - "self_serve_business_usage_based", - "business", - "ent26", - "enterprise_cbp_automation", - "enterprise_cbp_usage_based", - "enterprise", - "edu", - "edu_plus", - "edu_pro", - "unknown", -]).annotate({ identifier: "V2AccountRateLimitsUpdatedNotification__PlanType" }); +export type V2AccountRateLimitsUpdatedNotification__PlanType = string; +export const V2AccountRateLimitsUpdatedNotification__PlanType = Schema.String.annotate({ + identifier: "V2AccountRateLimitsUpdatedNotification__PlanType", +}); export type V2AccountRateLimitsUpdatedNotification__RateLimitWindow = { readonly resetsAt?: number | null; @@ -4260,43 +4194,10 @@ export const V2AccountUpdatedNotification__AuthMode = Schema.Union( identifier: "V2AccountUpdatedNotification__AuthMode", }); -export type V2AccountUpdatedNotification__PlanType = - | "free" - | "go" - | "plus" - | "pro" - | "prolite" - | "team" - | "self_serve_business_prolite" - | "self_serve_business_usage_based" - | "business" - | "ent26" - | "enterprise_cbp_automation" - | "enterprise_cbp_usage_based" - | "enterprise" - | "edu" - | "edu_plus" - | "edu_pro" - | "unknown"; -export const V2AccountUpdatedNotification__PlanType = Schema.Literals([ - "free", - "go", - "plus", - "pro", - "prolite", - "team", - "self_serve_business_prolite", - "self_serve_business_usage_based", - "business", - "ent26", - "enterprise_cbp_automation", - "enterprise_cbp_usage_based", - "enterprise", - "edu", - "edu_plus", - "edu_pro", - "unknown", -]).annotate({ identifier: "V2AccountUpdatedNotification__PlanType" }); +export type V2AccountUpdatedNotification__PlanType = string; +export const V2AccountUpdatedNotification__PlanType = Schema.String.annotate({ + identifier: "V2AccountUpdatedNotification__PlanType", +}); export type V2AppListUpdatedNotification__AppReview = { readonly status: string }; export const V2AppListUpdatedNotification__AppReview = Schema.Struct({ @@ -5484,43 +5385,10 @@ export const V2GetAccountRateLimitsResponse__SpendControlLimitSnapshot = Schema. used: Schema.String, }).annotate({ identifier: "V2GetAccountRateLimitsResponse__SpendControlLimitSnapshot" }); -export type V2GetAccountRateLimitsResponse__PlanType = - | "free" - | "go" - | "plus" - | "pro" - | "prolite" - | "team" - | "self_serve_business_prolite" - | "self_serve_business_usage_based" - | "business" - | "ent26" - | "enterprise_cbp_automation" - | "enterprise_cbp_usage_based" - | "enterprise" - | "edu" - | "edu_plus" - | "edu_pro" - | "unknown"; -export const V2GetAccountRateLimitsResponse__PlanType = Schema.Literals([ - "free", - "go", - "plus", - "pro", - "prolite", - "team", - "self_serve_business_prolite", - "self_serve_business_usage_based", - "business", - "ent26", - "enterprise_cbp_automation", - "enterprise_cbp_usage_based", - "enterprise", - "edu", - "edu_plus", - "edu_pro", - "unknown", -]).annotate({ identifier: "V2GetAccountRateLimitsResponse__PlanType" }); +export type V2GetAccountRateLimitsResponse__PlanType = string; +export const V2GetAccountRateLimitsResponse__PlanType = Schema.String.annotate({ + identifier: "V2GetAccountRateLimitsResponse__PlanType", +}); export type V2GetAccountRateLimitsResponse__RateLimitWindow = { readonly resetsAt?: number | null; @@ -5563,43 +5431,10 @@ export const V2GetAccountRateLimitsResponse__RateLimitReachedType = Schema.Liter "workspace_member_usage_limit_reached", ]).annotate({ identifier: "V2GetAccountRateLimitsResponse__RateLimitReachedType" }); -export type V2GetAccountResponse__PlanType = - | "free" - | "go" - | "plus" - | "pro" - | "prolite" - | "team" - | "self_serve_business_prolite" - | "self_serve_business_usage_based" - | "business" - | "ent26" - | "enterprise_cbp_automation" - | "enterprise_cbp_usage_based" - | "enterprise" - | "edu" - | "edu_plus" - | "edu_pro" - | "unknown"; -export const V2GetAccountResponse__PlanType = Schema.Literals([ - "free", - "go", - "plus", - "pro", - "prolite", - "team", - "self_serve_business_prolite", - "self_serve_business_usage_based", - "business", - "ent26", - "enterprise_cbp_automation", - "enterprise_cbp_usage_based", - "enterprise", - "edu", - "edu_plus", - "edu_pro", - "unknown", -]).annotate({ identifier: "V2GetAccountResponse__PlanType" }); +export type V2GetAccountResponse__PlanType = string; +export const V2GetAccountResponse__PlanType = Schema.String.annotate({ + identifier: "V2GetAccountResponse__PlanType", +}); export type V2GetAccountResponse__AccountRoutingOverride = "NO_CONSTRAINT" | "us" | "us_cr"; export const V2GetAccountResponse__AccountRoutingOverride = Schema.Literals([ diff --git a/packages/effect-codex-app-server/src/schema.test.ts b/packages/effect-codex-app-server/src/schema.test.ts index b12b33e02854..cc88b9e38363 100644 --- a/packages/effect-codex-app-server/src/schema.test.ts +++ b/packages/effect-codex-app-server/src/schema.test.ts @@ -201,13 +201,15 @@ it("accepts Codex misalignment policy errors for thread responses", () => { ); }); -it("accepts Codex 0.150 account plan values", () => { +it("accepts account plan slugs newer than the pinned protocol", () => { const planTypes = [ "self_serve_business_prolite", "ent26", "enterprise_cbp_automation", "edu_plus", "edu_pro", + "promax", + "some_future_plan", ]; for (const planType of planTypes) { From 1a553d0f5dd2aa39a64895ec770dc51ef1448b1a Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 29 Sep 2026 15:28:50 -0500 Subject: [PATCH 03/28] fix(shared): merge OpenCode Go limits by credential (#14209) --- .../provider/Layers/OpenCodeProvider.test.ts | 6 ++ .../provider/Layers/openCodeUsageLimits.ts | 12 ++- packages/contracts/src/providerUsageLimits.ts | 2 + packages/shared/src/usageLimits.test.ts | 68 +++++++++++++++++ packages/shared/src/usageLimits.ts | 74 +++++++++++-------- 5 files changed, 130 insertions(+), 32 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b50a51f6cb31..55e159197e6b 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -1,4 +1,5 @@ import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -71,6 +72,10 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res Effect.provide(NodeServices.layer), ); NodeAssert.equal(limits.unavailable, undefined); + NodeAssert.equal( + limits.credentialFingerprint, + NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"), + ); NodeAssert.deepEqual( limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ kind, @@ -139,6 +144,7 @@ it.effect("keeps Go entitlement absence distinct from failed or malformed usage Effect.provide(NodeServices.layer), ); NodeAssert.equal(limits.unavailable?.reason, reason); + NodeAssert.equal(limits.credentialFingerprint, undefined); NodeAssert.deepEqual(limits.windows, []); } }), diff --git a/apps/server/src/provider/Layers/openCodeUsageLimits.ts b/apps/server/src/provider/Layers/openCodeUsageLimits.ts index 22b542142cc4..74d0bd75b9f8 100644 --- a/apps/server/src/provider/Layers/openCodeUsageLimits.ts +++ b/apps/server/src/provider/Layers/openCodeUsageLimits.ts @@ -1,4 +1,5 @@ import * as NodeOS from "node:os"; +import * as NodeCrypto from "node:crypto"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -95,7 +96,16 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), }, ]; - return makeUsageLimits({ checkedAt, windows }); + return { + ...makeUsageLimits({ checkedAt, windows }), + // Go's usage response has no account ID. An unkeyed hash matches across + // environments without a shared secret. It permits offline guesses, but + // Go keys are randomly generated. + credentialFingerprint: NodeCrypto.createHash("sha256") + .update("opencode-go\0") + .update(apiKey) + .digest("hex"), + }; }).pipe( Effect.timeout("5 seconds"), Effect.orElseSucceed(() => diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 5576d27f5dbb..c692a9b7d519 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -50,6 +50,8 @@ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, windows: ForwardCompatibleArray(ServerProviderUsageWindow), + /** Opaque credential identity when the provider does not report an account. */ + credentialFingerprint: Schema.optional(TrimmedNonEmptyString), resetCredits: Schema.optional(ServerProviderResetCredits), /** Provider-owned usage settings when quota windows are not available to the client. */ externalUsage: Schema.optional( diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 94ec8983ea0c..e93c68d1afdb 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -207,6 +207,74 @@ describe("pools", () => { expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); }); + it("merges OpenCode Go limits from machines with the same API key", () => { + const go = provider({ + driver: ProviderDriverKind.make("opencode"), + instanceId: ProviderInstanceId.make("opencode"), + auth: { status: "authenticated" }, + usageLimits: { + checkedAt, + credentialFingerprint: "shared-go-key", + windows: [{ ...window, id: "go_rolling", usedPercent: 3 }], + }, + }); + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [go] } }], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [ + { + ...go, + usageLimits: { + ...go.usageLimits!, + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, id: "go_rolling", usedPercent: 4 }], + }, + }, + ], + }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.environments).toEqual([ + { environmentId: "env-a", label: "Laptop" }, + { environmentId: "env-b", label: "Desktop" }, + ]); + expect(collectLimitPools(accounts, now)[0]?.windows[0]?.members).toHaveLength(1); + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(4); + + const differentKey = { + ...go, + usageLimits: { ...go.usageLimits!, credentialFingerprint: "other-go-key" }, + }; + input.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [differentKey] }, + }); + expect(collectLimitAccounts(input)).toHaveLength(2); + + input.set(EnvironmentId.make("env-a"), { + ...laptop, + serverConfig: { + providers: [{ ...go, auth: { status: "authenticated", email: "same@example.com" } }], + }, + }); + input.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [ + { ...differentKey, auth: { status: "authenticated", email: "SAME@example.com" } }, + ], + }, + }); + expect(collectLimitAccounts(input)).toHaveLength(1); + }); + it("takes windows from a fresher hub read but credits and redeem from the native instance", () => { const native = provider({ driver: claude, diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 53b4a0a3c7da..b969ffb1c531 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -136,16 +136,24 @@ export function collectExternalUsageLinks(presentations: LimitPresentations) { return [...links.values()]; } -function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null { +/** Prefer the reported email; use an identical credential when no email is available. */ +function accountKey( + driver: ServerProvider["driver"], + email: string | undefined, + limits?: ServerProviderUsageLimits, +): string | null { const normalizedEmail = email?.trim().toLowerCase(); - return normalizedEmail ? `${driver}:${normalizedEmail}` : null; + if (normalizedEmail) return `${driver}:${normalizedEmail}`; + return limits?.credentialFingerprint + ? `${driver}:credential:${limits.credentialFingerprint}` + : null; } /** * One subscription account as the pooled views see it, whichever way it was - * reported. The same email signed in natively on two environments, or reported - * by a hub as well as natively, is one account: its quota is one bucket, so - * counting it twice would misstate what is left. + * reported. Matching emails or credentials across environments name + * one account. Its quota is one bucket, so counting it twice would misstate + * what is left. */ export interface LimitAccount { readonly key: string; @@ -243,7 +251,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; merge( - accountKey(provider.driver, provider.auth.email) ?? + accountKey(provider.driver, provider.auth.email, provider.usageLimits) ?? `${environmentId}:${provider.instanceId}`, { key: `${environmentId}:${provider.instanceId}`, @@ -271,27 +279,31 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl : source.label; for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; - merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, { - key: `${source.id}:${account.id}`, - driver: account.driver, - displayName: account.email ? null : account.id.replace(/\.json$/i, ""), - email: account.email, - plan: account.plan, - accentColor: undefined, - environments: [], - sourceLabel, - redeem: account.usageLimits.resetCredits?.nextCreditId - ? { - environmentId, - input: { - sourceId: source.id, - accountId: account.id, - creditId: account.usageLimits.resetCredits.nextCreditId, - }, - } - : null, - limits: account.usageLimits, - }); + merge( + accountKey(account.driver, account.email, account.usageLimits) ?? + `${source.id}:${account.id}`, + { + key: `${source.id}:${account.id}`, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: account.usageLimits.resetCredits?.nextCreditId + ? { + environmentId, + input: { + sourceId: source.id, + accountId: account.id, + creditId: account.usageLimits.resetCredits.nextCreditId, + }, + } + : null, + limits: account.usageLimits, + }, + ); } } } @@ -647,7 +659,7 @@ export function collectProviderUsageLimits( ); const nativeAccounts = new Set( native.flatMap((provider) => { - const key = accountKey(provider.driver, provider.auth.email); + const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable ? [key] : []; @@ -657,13 +669,13 @@ export function collectProviderUsageLimits( const notices: string[] = []; for (const provider of native) { if (!provider.usageLimits) continue; - const key = accountKey(provider.driver, provider.auth.email); + const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); const hubCredits = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .filter( ({ account }) => key !== null && - accountKey(account.driver, account.email) === key && + accountKey(account.driver, account.email, account.usageLimits) === key && account.usageLimits.resetCredits && !limitsNotice(account.usageLimits), ) @@ -710,7 +722,7 @@ export function collectProviderUsageLimits( for (const source of sources) { const matching = source.accounts.filter((account) => account.driver === selected.driver); for (const account of matching) { - const key = accountKey(account.driver, account.email); + const key = accountKey(account.driver, account.email, account.usageLimits); if (key && nativeAccounts.has(key)) continue; accounts.push({ id: `${source.id}:${account.id}`, From 422248515a1eebff607b44d5abef91e13f6228fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 29 Sep 2026 13:31:23 -0700 Subject: [PATCH 04/28] feat(codex): regenerate protocol bindings for Codex 0.159 (#14311) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/provider/model-manifest.json | 14 +- .../scripts/generate.ts | 5 +- .../src/_generated/meta.gen.ts | 20 +- .../src/_generated/namespaces.gen.ts | 6 +- .../src/_generated/schema.gen.ts | 563 +++++++++++++++--- 5 files changed, 515 insertions(+), 93 deletions(-) diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 5eb0b3db507a..f11972dae6c2 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,10 +1,20 @@ { "version": 1, - "updatedAt": "2026-09-29T01:03:44Z", + "updatedAt": "2026-09-29T20:20:00Z", "compatibility": [ { "driver": "codex", - "t3CodeRange": ">=0.0.42", + "t3CodeRange": ">=0.0.44", + "recommendedRange": ">=0.159.0", + "ranges": [ + { "range": ">=0.156.0", "status": "supported" }, + { "range": ">=0.149.0 <0.156.0", "status": "unsupported" }, + { "range": "<0.149.0", "status": "broken" } + ] + }, + { + "driver": "codex", + "t3CodeRange": ">=0.0.42 <0.0.44", "recommendedRange": ">=0.156.0", "ranges": [ { "range": ">=0.156.0", "status": "supported" }, diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 8825f6044fc2..f7cab8e59b93 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 = "fe74a774532af67b5a4a3dec03ce9469e17f89af"; +const UPSTREAM_REF = "687a119f0fcaace47e1f1abcc77cec6c813fd6da"; 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"; @@ -437,6 +437,9 @@ function resolveResponseTypeName( generatedSchemaNames: ReadonlySet, ): string { const overrides: Record = { + "account/gatewayOAuth/cancel": "GatewayOAuthCancelResponse", + "account/gatewayOAuth/login": "GatewayOAuthLoginResponse", + "account/gatewayOAuth/read": "GatewayOAuthReadResponse", "account/logout": "LogoutAccountResponse", "account/rateLimits/read": "GetAccountRateLimitsResponse", "account/usage/read": "GetAccountTokenUsageResponse", 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 88ffb842ac9b..1c9d47e58523 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: fe74a774532af67b5a4a3dec03ce9469e17f89af +// Upstream protocol ref: 687a119f0fcaace47e1f1abcc77cec6c813fd6da import * as CodexSchema from "./schema.gen.ts"; @@ -71,6 +71,9 @@ export const CLIENT_REQUEST_METHODS = { "turn/interrupt": "turn/interrupt", "review/start": "review/start", "model/list": "model/list", + "account/gatewayOAuth/read": "account/gatewayOAuth/read", + "account/gatewayOAuth/login": "account/gatewayOAuth/login", + "account/gatewayOAuth/cancel": "account/gatewayOAuth/cancel", "modelProvider/capabilities/read": "modelProvider/capabilities/read", "experimentalFeature/list": "experimentalFeature/list", "permissionProfile/list": "permissionProfile/list", @@ -176,6 +179,7 @@ export const SERVER_NOTIFICATION_METHODS = { "mcpServer/startupStatus/updated": "mcpServer/startupStatus/updated", "mcpServer/event/stream/notification": "mcpServer/event/stream/notification", "account/updated": "account/updated", + "account/gatewayOAuth/changed": "account/gatewayOAuth/changed", "account/rateLimits/updated": "account/rateLimits/updated", "app/list/updated": "app/list/updated", "remoteControl/status/changed": "remoteControl/status/changed", @@ -287,6 +291,9 @@ export interface ClientRequestParamsByMethod { readonly "turn/interrupt": CodexSchema.V2TurnInterruptParams; readonly "review/start": CodexSchema.V2ReviewStartParams; readonly "model/list": CodexSchema.V2ModelListParams; + readonly "account/gatewayOAuth/read": undefined; + readonly "account/gatewayOAuth/login": undefined; + readonly "account/gatewayOAuth/cancel": undefined; readonly "modelProvider/capabilities/read": CodexSchema.V2ModelProviderCapabilitiesReadParams; readonly "experimentalFeature/list": CodexSchema.V2ExperimentalFeatureListParams; readonly "permissionProfile/list": CodexSchema.V2PermissionProfileListParams; @@ -394,6 +401,9 @@ export interface ClientRequestResponsesByMethod { readonly "turn/interrupt": CodexSchema.V2TurnInterruptResponse; readonly "review/start": CodexSchema.V2ReviewStartResponse; readonly "model/list": CodexSchema.V2ModelListResponse; + readonly "account/gatewayOAuth/read": CodexSchema.V2GatewayOAuthReadResponse; + readonly "account/gatewayOAuth/login": CodexSchema.V2GatewayOAuthLoginResponse; + readonly "account/gatewayOAuth/cancel": CodexSchema.V2GatewayOAuthCancelResponse; readonly "modelProvider/capabilities/read": CodexSchema.V2ModelProviderCapabilitiesReadResponse; readonly "experimentalFeature/list": CodexSchema.V2ExperimentalFeatureListResponse; readonly "permissionProfile/list": CodexSchema.V2PermissionProfileListResponse; @@ -512,6 +522,7 @@ export interface ServerNotificationParamsByMethod { readonly "mcpServer/startupStatus/updated": CodexSchema.V2McpServerStatusUpdatedNotification; readonly "mcpServer/event/stream/notification": CodexSchema.V2McpServerEventStreamNotification; readonly "account/updated": CodexSchema.V2AccountUpdatedNotification; + readonly "account/gatewayOAuth/changed": CodexSchema.V2GatewayOAuthChangedNotification; readonly "account/rateLimits/updated": CodexSchema.V2AccountRateLimitsUpdatedNotification; readonly "app/list/updated": CodexSchema.V2AppListUpdatedNotification; readonly "remoteControl/status/changed": CodexSchema.V2RemoteControlStatusChangedNotification; @@ -618,6 +629,9 @@ export const CLIENT_REQUEST_PARAMS = { "turn/interrupt": CodexSchema.V2TurnInterruptParams, "review/start": CodexSchema.V2ReviewStartParams, "model/list": CodexSchema.V2ModelListParams, + "account/gatewayOAuth/read": undefined, + "account/gatewayOAuth/login": undefined, + "account/gatewayOAuth/cancel": undefined, "modelProvider/capabilities/read": CodexSchema.V2ModelProviderCapabilitiesReadParams, "experimentalFeature/list": CodexSchema.V2ExperimentalFeatureListParams, "permissionProfile/list": CodexSchema.V2PermissionProfileListParams, @@ -726,6 +740,9 @@ export const CLIENT_REQUEST_RESPONSES = { "turn/interrupt": CodexSchema.V2TurnInterruptResponse, "review/start": CodexSchema.V2ReviewStartResponse, "model/list": CodexSchema.V2ModelListResponse, + "account/gatewayOAuth/read": CodexSchema.V2GatewayOAuthReadResponse, + "account/gatewayOAuth/login": CodexSchema.V2GatewayOAuthLoginResponse, + "account/gatewayOAuth/cancel": CodexSchema.V2GatewayOAuthCancelResponse, "modelProvider/capabilities/read": CodexSchema.V2ModelProviderCapabilitiesReadResponse, "experimentalFeature/list": CodexSchema.V2ExperimentalFeatureListResponse, "permissionProfile/list": CodexSchema.V2PermissionProfileListResponse, @@ -847,6 +864,7 @@ export const SERVER_NOTIFICATION_PARAMS = { "mcpServer/startupStatus/updated": CodexSchema.V2McpServerStatusUpdatedNotification, "mcpServer/event/stream/notification": CodexSchema.V2McpServerEventStreamNotification, "account/updated": CodexSchema.V2AccountUpdatedNotification, + "account/gatewayOAuth/changed": CodexSchema.V2GatewayOAuthChangedNotification, "account/rateLimits/updated": CodexSchema.V2AccountRateLimitsUpdatedNotification, "app/list/updated": CodexSchema.V2AppListUpdatedNotification, "remoteControl/status/changed": CodexSchema.V2RemoteControlStatusChangedNotification, 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 3428ce049ed7..47474330d286 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: fe74a774532af67b5a4a3dec03ce9469e17f89af +// Upstream protocol ref: 687a119f0fcaace47e1f1abcc77cec6c813fd6da import * as CodexSchema from "./schema.gen.ts"; @@ -88,6 +88,10 @@ export const v2 = { FsWatchResponse: CodexSchema.V2FsWatchResponse, FsWriteFileParams: CodexSchema.V2FsWriteFileParams, FsWriteFileResponse: CodexSchema.V2FsWriteFileResponse, + GatewayOAuthCancelResponse: CodexSchema.V2GatewayOAuthCancelResponse, + GatewayOAuthChangedNotification: CodexSchema.V2GatewayOAuthChangedNotification, + GatewayOAuthLoginResponse: CodexSchema.V2GatewayOAuthLoginResponse, + GatewayOAuthReadResponse: CodexSchema.V2GatewayOAuthReadResponse, GetAccountParams: CodexSchema.V2GetAccountParams, GetAccountRateLimitsResponse: CodexSchema.V2GetAccountRateLimitsResponse, GetAccountResponse: CodexSchema.V2GetAccountResponse, 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 e9ac22ec120b..38b46712257a 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: fe74a774532af67b5a4a3dec03ce9469e17f89af +// Upstream protocol ref: 687a119f0fcaace47e1f1abcc77cec6c813fd6da import * as Schema from "effect/Schema"; @@ -57,6 +57,7 @@ export const ClientRequest__RequestId = Schema.Union([ export type ClientRequest__InitializeCapabilities = { readonly experimentalApi?: boolean; + readonly explicitGatewayOauth?: boolean; readonly extensions?: { readonly [x: string]: Schema.Json } | null; readonly mcpServerOpenaiFormElicitation?: boolean; readonly optOutNotificationMethods?: ReadonlyArray | null; @@ -69,6 +70,12 @@ export const ClientRequest__InitializeCapabilities = Schema.Struct({ default: false, }), ), + explicitGatewayOauth: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Use explicit gateway OAuth login instead of automatic browser authorization. Applies to this app-server's gateway runtime; later connections cannot undo it.", + }), + ), extensions: Schema.optionalKey( Schema.Union([ Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ @@ -572,6 +579,23 @@ export const ClientRequest__TurnItemsView = Schema.Union( { mode: "oneOf" }, ).annotate({ identifier: "ClientRequest__TurnItemsView" }); +export type ClientRequest__ThreadItemsListAnchor = { + readonly itemId: string; + readonly type: "item"; +}; +export const ClientRequest__ThreadItemsListAnchor = Schema.Union( + [ + Schema.Struct({ + itemId: Schema.String, + type: Schema.Literal("item").annotate({ title: "ItemThreadItemsListAnchorType" }), + }).annotate({ title: "ItemThreadItemsListAnchor" }), + ], + { mode: "oneOf" }, +).annotate({ + description: "An exclusive item position within the requested visible turn.", + identifier: "ClientRequest__ThreadItemsListAnchor", +}); + export type ClientRequest__ThreadInjectItemsParams = { readonly items: ReadonlyArray; readonly threadId: string; @@ -1094,27 +1118,19 @@ export const ClientRequest__McpServerStatusDetail = Schema.Literals([ "toolsAndAuthOnly", ]).annotate({ identifier: "ClientRequest__McpServerStatusDetail" }); -export type ClientRequest__McpResourceReadParams = { - readonly connectorId?: string | null; - readonly originCallId?: string | null; - readonly server: string; - readonly threadId?: string | null; - readonly uri: string; +export type ClientRequest__McpResourceReadTarget = { + readonly connectorId: string; + readonly linkId: string | null; }; -export const ClientRequest__McpResourceReadParams = Schema.Struct({ - connectorId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - originCallId: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: "Originating MCP tool call used to select the resource's app.", - }), - Schema.Null, - ]), - ), - server: Schema.String, - threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - uri: Schema.String, -}).annotate({ identifier: "ClientRequest__McpResourceReadParams" }); +export const ClientRequest__McpResourceReadTarget = Schema.Struct({ + connectorId: Schema.String, + linkId: Schema.Union([ + Schema.String.annotate({ + description: "Null explicitly requests no-auth access, subject to the app's resource policy.", + }), + Schema.Null, + ]), +}).annotate({ identifier: "ClientRequest__McpResourceReadTarget" }); export type ClientRequest__McpServerToolCallParams = { readonly _meta?: Schema.Json; @@ -3209,6 +3225,18 @@ export const ServerNotification__PlanType = Schema.String.annotate({ identifier: "ServerNotification__PlanType", }); +export type ServerNotification__GatewayOAuthStatus = + | "notReady" + | "started" + | "succeeded" + | "failed"; +export const ServerNotification__GatewayOAuthStatus = Schema.Literals([ + "notReady", + "started", + "succeeded", + "failed", +]).annotate({ identifier: "ServerNotification__GatewayOAuthStatus" }); + export type ServerNotification__CreditsSnapshot = { readonly balance?: string | null; readonly hasCredits: boolean; @@ -4008,6 +4036,7 @@ export const ToolRequestUserInputResponse__ToolRequestUserInputAnswer = Schema.S export type V1InitializeParams__InitializeCapabilities = { readonly experimentalApi?: boolean; + readonly explicitGatewayOauth?: boolean; readonly extensions?: { readonly [x: string]: Schema.Json } | null; readonly mcpServerOpenaiFormElicitation?: boolean; readonly optOutNotificationMethods?: ReadonlyArray | null; @@ -4020,6 +4049,12 @@ export const V1InitializeParams__InitializeCapabilities = Schema.Struct({ default: false, }), ), + explicitGatewayOauth: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Use explicit gateway OAuth login instead of automatic browser authorization. Applies to this app-server's gateway runtime; later connections cannot undo it.", + }), + ), extensions: Schema.optionalKey( Schema.Union([ Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ @@ -5339,6 +5374,30 @@ export const V2FsWriteFileParams__AbsolutePathBuf = Schema.String.annotate({ identifier: "V2FsWriteFileParams__AbsolutePathBuf", }); +export type V2GatewayOAuthChangedNotification__GatewayOAuthStatus = + | "notReady" + | "started" + | "succeeded" + | "failed"; +export const V2GatewayOAuthChangedNotification__GatewayOAuthStatus = Schema.Literals([ + "notReady", + "started", + "succeeded", + "failed", +]).annotate({ identifier: "V2GatewayOAuthChangedNotification__GatewayOAuthStatus" }); + +export type V2GatewayOAuthReadResponse__GatewayOAuthStatus = + | "notReady" + | "started" + | "succeeded" + | "failed"; +export const V2GatewayOAuthReadResponse__GatewayOAuthStatus = Schema.Literals([ + "notReady", + "started", + "succeeded", + "failed", +]).annotate({ identifier: "V2GatewayOAuthReadResponse__GatewayOAuthStatus" }); + export type V2GetAccountRateLimitsResponse__RateLimitResetType = "codexRateLimits" | "unknown"; export const V2GetAccountRateLimitsResponse__RateLimitResetType = Schema.Literals([ "codexRateLimits", @@ -6966,6 +7025,20 @@ export const V2MarketplaceUpgradeResponse__AbsolutePathBuf = Schema.String.annot identifier: "V2MarketplaceUpgradeResponse__AbsolutePathBuf", }); +export type V2McpResourceReadParams__McpResourceReadTarget = { + readonly connectorId: string; + readonly linkId: string | null; +}; +export const V2McpResourceReadParams__McpResourceReadTarget = Schema.Struct({ + connectorId: Schema.String, + linkId: Schema.Union([ + Schema.String.annotate({ + description: "Null explicitly requests no-auth access, subject to the app's resource policy.", + }), + Schema.Null, + ]), +}).annotate({ identifier: "V2McpResourceReadParams__McpResourceReadTarget" }); + export type V2McpResourceReadResponse__ResourceContent = | { readonly _meta?: Schema.Json; @@ -9233,6 +9306,23 @@ export const V2ThreadGoalUpdatedNotification__ThreadGoalStatus = Schema.Literals "complete", ]).annotate({ identifier: "V2ThreadGoalUpdatedNotification__ThreadGoalStatus" }); +export type V2ThreadItemsListParams__ThreadItemsListAnchor = { + readonly itemId: string; + readonly type: "item"; +}; +export const V2ThreadItemsListParams__ThreadItemsListAnchor = Schema.Union( + [ + Schema.Struct({ + itemId: Schema.String, + type: Schema.Literal("item").annotate({ title: "ItemThreadItemsListAnchorType" }), + }).annotate({ title: "ItemThreadItemsListAnchor" }), + ], + { mode: "oneOf" }, +).annotate({ + description: "An exclusive item position within the requested visible turn.", + identifier: "V2ThreadItemsListParams__ThreadItemsListAnchor", +}); + export type V2ThreadItemsListParams__SortDirection = "asc" | "desc"; export const V2ThreadItemsListParams__SortDirection = Schema.Literals(["asc", "desc"]).annotate({ identifier: "V2ThreadItemsListParams__SortDirection", @@ -16276,51 +16366,6 @@ export const ClientRequest__ThreadMetadataUpdateParams = Schema.Struct({ threadId: Schema.String, }).annotate({ identifier: "ClientRequest__ThreadMetadataUpdateParams" }); -export type ClientRequest__ThreadItemsListParams = { - readonly cursor?: string | null; - readonly limit?: number | null; - readonly sortDirection?: ClientRequest__SortDirection | null; - readonly threadId: string; - readonly turnId?: string | null; -}; -export const ClientRequest__ThreadItemsListParams = Schema.Struct({ - cursor: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: "Opaque cursor to pass to the next call to continue after the last item.", - }), - Schema.Null, - ]), - ), - limit: Schema.optionalKey( - Schema.Union([ - Schema.Number.annotate({ description: "Optional item page size.", format: "uint32" }) - .check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(0).annotate({ - expected: "a value greater than or equal to 0", - }), - ), - Schema.Null, - ]), - ), - sortDirection: Schema.optionalKey( - Schema.Union([ClientRequest__SortDirection, Schema.Null]).annotate({ - description: "Optional item pagination direction; defaults to ascending.", - }), - ), - threadId: Schema.String, - turnId: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: - "Optional turn id to filter by. When omitted, returns items across the thread.", - }), - Schema.Null, - ]), - ), -}).annotate({ identifier: "ClientRequest__ThreadItemsListParams" }); - export type ClientRequest__ThreadListParams = { readonly archived?: boolean | null; readonly cursor?: string | null; @@ -16511,6 +16556,15 @@ export const ClientRequest__ThreadTurnsListParams = Schema.Struct({ threadId: Schema.String, }).annotate({ identifier: "ClientRequest__ThreadTurnsListParams" }); +export type ClientRequest__ThreadItemsListCursor = string | ClientRequest__ThreadItemsListAnchor; +export const ClientRequest__ThreadItemsListCursor = Schema.Union([ + Schema.String, + ClientRequest__ThreadItemsListAnchor, +]).annotate({ + description: "Starting position for an item-history page.", + identifier: "ClientRequest__ThreadItemsListCursor", +}); + export type ClientRequest__SkillsExtraRootsSetParams = { readonly extraRoots: ReadonlyArray; }; @@ -17003,6 +17057,7 @@ export type ClientRequest__ListMcpServerStatusParams = { readonly cursor?: string | null; readonly detail?: ClientRequest__McpServerStatusDetail | null; readonly limit?: number | null; + readonly serverName?: string | null; readonly threadId?: string | null; }; export const ClientRequest__ListMcpServerStatusParams = Schema.Struct({ @@ -17035,9 +17090,46 @@ export const ClientRequest__ListMcpServerStatusParams = Schema.Struct({ Schema.Null, ]), ), + serverName: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Limit discovery to one server. With a thread ID, reuse that thread's MCP connection.", + }), + Schema.Null, + ]), + ), threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }).annotate({ identifier: "ClientRequest__ListMcpServerStatusParams" }); +export type ClientRequest__McpResourceReadParams = { + readonly connectorId?: string | null; + readonly originCallId?: string | null; + readonly server: string; + readonly target?: ClientRequest__McpResourceReadTarget | null; + readonly threadId?: string | null; + readonly uri: string; +}; +export const ClientRequest__McpResourceReadParams = Schema.Struct({ + connectorId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + originCallId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Originating MCP tool call used to select the resource's app.", + }), + Schema.Null, + ]), + ), + server: Schema.String, + target: Schema.optionalKey( + Schema.Union([ClientRequest__McpResourceReadTarget, Schema.Null]).annotate({ + description: "Explicit hosted app/account. Omit to retain legacy resource discovery.", + }), + ), + threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, +}).annotate({ identifier: "ClientRequest__McpResourceReadParams" }); + export type ClientRequest__WindowsSandboxSetupStartParams = { readonly cwd?: ClientRequest__AbsolutePathBuf | null; readonly mode: ClientRequest__WindowsSandboxSetupMode; @@ -17771,9 +17863,11 @@ export type ServerNotification__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -17796,9 +17890,11 @@ export const ServerNotification__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -18501,6 +18597,26 @@ export const ServerNotification__AccountUpdatedNotification = Schema.Struct({ planType: Schema.optionalKey(Schema.Union([ServerNotification__PlanType, Schema.Null])), }).annotate({ identifier: "ServerNotification__AccountUpdatedNotification" }); +export type ServerNotification__GatewayOAuthChangedNotification = { + readonly authUrl?: string | null; + readonly error?: string | null; + readonly providerId: string; + readonly status: ServerNotification__GatewayOAuthStatus; +}; +export const ServerNotification__GatewayOAuthChangedNotification = Schema.Struct({ + authUrl: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Authorization handoff, sent only to the connection that started login.", + }), + Schema.Null, + ]), + ), + error: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + providerId: Schema.String, + status: ServerNotification__GatewayOAuthStatus, +}).annotate({ identifier: "ServerNotification__GatewayOAuthChangedNotification" }); + export type ServerNotification__RateLimitSnapshot = { readonly credits?: ServerNotification__CreditsSnapshot | null; readonly individualLimit?: ServerNotification__SpendControlLimitSnapshot | null; @@ -19833,9 +19949,11 @@ export type V2ErrorNotification__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -19858,9 +19976,11 @@ export const V2ErrorNotification__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -21307,6 +21427,7 @@ export const V2ItemStartedNotification__CollabAgentState = Schema.Struct({ export type V2ListMcpServerStatusResponse__McpServerStatus = { readonly authStatus: V2ListMcpServerStatusResponse__McpAuthStatus; + readonly httpOrigin?: string | null; readonly name: string; readonly pluginId?: string | null; readonly resourceTemplates: ReadonlyArray; @@ -21319,6 +21440,15 @@ export type V2ListMcpServerStatusResponse__McpServerStatus = { }; export const V2ListMcpServerStatusResponse__McpServerStatus = Schema.Struct({ authStatus: V2ListMcpServerStatusResponse__McpAuthStatus, + httpOrigin: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "HTTP origin of the effective configured endpoint, including plugin servers. Excludes credentials, path, query, and fragment; null for non-HTTP transports.", + }), + Schema.Null, + ]), + ), name: Schema.String, pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), resourceTemplates: Schema.Array(V2ListMcpServerStatusResponse__ResourceTemplate), @@ -22275,9 +22405,11 @@ export type V2ReviewStartResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -22300,9 +22432,11 @@ export const V2ReviewStartResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -22809,9 +22943,11 @@ export type V2ThreadForkResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -22834,9 +22970,11 @@ export const V2ThreadForkResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -23193,6 +23331,17 @@ export const V2ThreadGoalUpdatedNotification__ThreadGoal = Schema.Struct({ ), }).annotate({ identifier: "V2ThreadGoalUpdatedNotification__ThreadGoal" }); +export type V2ThreadItemsListParams__ThreadItemsListCursor = + | string + | V2ThreadItemsListParams__ThreadItemsListAnchor; +export const V2ThreadItemsListParams__ThreadItemsListCursor = Schema.Union([ + Schema.String, + V2ThreadItemsListParams__ThreadItemsListAnchor, +]).annotate({ + description: "Starting position for an item-history page.", + identifier: "V2ThreadItemsListParams__ThreadItemsListCursor", +}); + export type V2ThreadItemsListResponse__TextElement = { readonly byteRange: V2ThreadItemsListResponse__ByteRange; readonly placeholder?: string | null; @@ -23444,9 +23593,11 @@ export type V2ThreadListResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -23469,9 +23620,11 @@ export const V2ThreadListResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -23849,9 +24002,11 @@ export type V2ThreadMetadataUpdateResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -23874,9 +24029,11 @@ export const V2ThreadMetadataUpdateResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -24259,9 +24416,11 @@ export type V2ThreadReadResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -24284,9 +24443,11 @@ export const V2ThreadReadResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -25023,9 +25184,11 @@ export type V2ThreadResumeResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -25048,9 +25211,11 @@ export const V2ThreadResumeResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -25390,9 +25555,11 @@ export type V2ThreadRevertResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -25415,9 +25582,11 @@ export const V2ThreadRevertResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -25926,9 +26095,11 @@ export type V2ThreadStartedNotification__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -25951,9 +26122,11 @@ export const V2ThreadStartedNotification__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -26427,9 +26600,11 @@ export type V2ThreadStartResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -26452,9 +26627,11 @@ export const V2ThreadStartResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -26753,9 +26930,11 @@ export type V2ThreadTurnsListResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -26778,9 +26957,11 @@ export const V2ThreadTurnsListResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -27162,9 +27343,11 @@ export type V2ThreadUnarchiveResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -27187,9 +27370,11 @@ export const V2ThreadUnarchiveResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -27489,9 +27674,11 @@ export type V2TurnCompletedNotification__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -27514,9 +27701,11 @@ export const V2TurnCompletedNotification__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -27826,9 +28015,11 @@ export type V2TurnStartedNotification__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -27851,9 +28042,11 @@ export const V2TurnStartedNotification__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -28290,9 +28483,11 @@ export type V2TurnStartResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -28315,9 +28510,11 @@ export const V2TurnStartResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -28693,6 +28890,49 @@ export const ApplyPatchApprovalResponse__ReviewDecision = Schema.Union( identifier: "ApplyPatchApprovalResponse__ReviewDecision", }); +export type ClientRequest__ThreadItemsListParams = { + readonly cursor?: ClientRequest__ThreadItemsListCursor | null; + readonly limit?: number | null; + readonly sortDirection?: ClientRequest__SortDirection | null; + readonly threadId: string; + readonly turnId?: string | null; +}; +export const ClientRequest__ThreadItemsListParams = Schema.Struct({ + cursor: Schema.optionalKey( + Schema.Union([ClientRequest__ThreadItemsListCursor, Schema.Null]).annotate({ + description: + "Opaque continuation cursor or an exclusive item anchor in the requested visible turn. An item anchor requires a non-empty `turnId`; ascending (the default) returns items after it, and descending returns items before it. Continue with the returned string cursor.", + }), + ), + limit: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Optional item page size.", format: "uint32" }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ), + Schema.Null, + ]), + ), + sortDirection: Schema.optionalKey( + Schema.Union([ClientRequest__SortDirection, Schema.Null]).annotate({ + description: "Optional item pagination direction; defaults to ascending.", + }), + ), + threadId: Schema.String, + turnId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional turn id to filter by. When omitted, returns items across the thread.", + }), + Schema.Null, + ]), + ), +}).annotate({ identifier: "ClientRequest__ThreadItemsListParams" }); + export type ClientRequest__PluginShareSaveParams = { readonly discoverability?: ClientRequest__PluginShareDiscoverability | null; readonly pluginPath: ClientRequest__AbsolutePathBuf; @@ -42625,7 +42865,7 @@ export const ServerNotification__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([ServerNotification__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43212,7 +43452,7 @@ export const V2ReviewStartResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ReviewStartResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43273,7 +43513,7 @@ export const V2ThreadForkResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadForkResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43303,11 +43543,33 @@ export const V2ThreadForkResponse__Turn = Schema.Struct({ }).annotate({ identifier: "V2ThreadForkResponse__Turn" }); export type V2ThreadItemsListResponse__ThreadItemEntry = { + readonly completedAtMs?: number | null; readonly item: V2ThreadItemsListResponse__ThreadItem; + readonly startedAtMs?: number | null; readonly turnId: string; }; export const V2ThreadItemsListResponse__ThreadItemEntry = Schema.Struct({ + completedAtMs: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: + "Unix timestamp (milliseconds) when the item completed, if recorded by the producer.", + format: "int64", + }).check(Schema.isInt().annotate({ expected: "an integer" })), + Schema.Null, + ]), + ), item: V2ThreadItemsListResponse__ThreadItem, + startedAtMs: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: + "Unix timestamp (milliseconds) when the item started, if recorded by the producer.", + format: "int64", + }).check(Schema.isInt().annotate({ expected: "an integer" })), + Schema.Null, + ]), + ), turnId: Schema.String.annotate({ description: "Turn containing this item." }), }).annotate({ identifier: "V2ThreadItemsListResponse__ThreadItemEntry" }); @@ -43342,7 +43604,7 @@ export const V2ThreadListResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadListResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43402,7 +43664,7 @@ export const V2ThreadMetadataUpdateResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadMetadataUpdateResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43463,7 +43725,7 @@ export const V2ThreadReadResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadReadResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43523,7 +43785,7 @@ export const V2ThreadResumeResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadResumeResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43584,7 +43846,7 @@ export const V2ThreadRevertResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadRevertResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43645,7 +43907,7 @@ export const V2ThreadStartedNotification__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadStartedNotification__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43706,7 +43968,7 @@ export const V2ThreadStartResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadStartResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43767,7 +44029,7 @@ export const V2ThreadTurnsListResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadTurnsListResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43828,7 +44090,7 @@ export const V2ThreadUnarchiveResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadUnarchiveResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43889,7 +44151,7 @@ export const V2TurnCompletedNotification__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2TurnCompletedNotification__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43950,7 +44212,7 @@ export const V2TurnStartedNotification__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2TurnStartedNotification__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -44011,7 +44273,7 @@ export const V2TurnStartResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2TurnStartResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -47165,6 +47427,21 @@ export type ClientRequest = readonly method: "model/list"; readonly params: ClientRequest__ModelListParams; } + | { + readonly id: ClientRequest__RequestId; + readonly method: "account/gatewayOAuth/read"; + readonly params?: null; + } + | { + readonly id: ClientRequest__RequestId; + readonly method: "account/gatewayOAuth/login"; + readonly params?: null; + } + | { + readonly id: ClientRequest__RequestId; + readonly method: "account/gatewayOAuth/cancel"; + readonly params?: null; + } | { readonly id: ClientRequest__RequestId; readonly method: "modelProvider/capabilities/read"; @@ -47744,6 +48021,27 @@ export const ClientRequest = Schema.Union( method: Schema.Literal("model/list").annotate({ title: "Model/listRequestMethod" }), params: ClientRequest__ModelListParams, }).annotate({ title: "Model/listRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("account/gatewayOAuth/read").annotate({ + title: "Account/gatewayOAuth/readRequestMethod", + }), + params: Schema.optionalKey(Schema.Null), + }).annotate({ title: "Account/gatewayOAuth/readRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("account/gatewayOAuth/login").annotate({ + title: "Account/gatewayOAuth/loginRequestMethod", + }), + params: Schema.optionalKey(Schema.Null), + }).annotate({ title: "Account/gatewayOAuth/loginRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("account/gatewayOAuth/cancel").annotate({ + title: "Account/gatewayOAuth/cancelRequestMethod", + }), + params: Schema.optionalKey(Schema.Null), + }).annotate({ title: "Account/gatewayOAuth/cancelRequest" }), Schema.Struct({ id: ClientRequest__RequestId, method: Schema.Literal("modelProvider/capabilities/read").annotate({ @@ -49495,6 +49793,11 @@ export type ServerNotification = readonly method: "account/updated"; readonly params: ServerNotification__AccountUpdatedNotification; } + | { + readonly emittedAtMs?: number; + readonly method: "account/gatewayOAuth/changed"; + readonly params: ServerNotification__GatewayOAuthChangedNotification; + } | { readonly emittedAtMs?: number; readonly method: "account/rateLimits/updated"; @@ -50284,6 +50587,19 @@ export const ServerNotification = Schema.Union( }), params: ServerNotification__AccountUpdatedNotification, }).annotate({ title: "Account/updatedNotification" }), + Schema.Struct({ + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt().annotate({ expected: "an integer" })), + ), + method: Schema.Literal("account/gatewayOAuth/changed").annotate({ + title: "Account/gatewayOAuth/changedNotificationMethod", + }), + params: ServerNotification__GatewayOAuthChangedNotification, + }).annotate({ title: "Account/gatewayOAuth/changedNotification" }), Schema.Struct({ emittedAtMs: Schema.optionalKey( Schema.Number.annotate({ @@ -52595,6 +52911,63 @@ export const V2FsWriteFileResponse = Schema.Record( description: "Successful response for `fs/writeFile`.", }); +export type V2GatewayOAuthCancelResponse = { readonly [x: string]: Schema.Json }; +export const V2GatewayOAuthCancelResponse = Schema.Record( + Schema.String, + Schema.Json.annotate({ expected: "JSON value" }), +).annotate({ title: "GatewayOAuthCancelResponse" }); + +export type V2GatewayOAuthChangedNotification = { + readonly authUrl?: string | null; + readonly error?: string | null; + readonly providerId: string; + readonly status: V2GatewayOAuthChangedNotification__GatewayOAuthStatus; +}; +export const V2GatewayOAuthChangedNotification = Schema.Struct({ + authUrl: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Authorization handoff, sent only to the connection that started login.", + }), + Schema.Null, + ]), + ), + error: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + providerId: Schema.String, + status: V2GatewayOAuthChangedNotification__GatewayOAuthStatus, +}).annotate({ title: "GatewayOAuthChangedNotification" }); + +export type V2GatewayOAuthLoginResponse = { readonly [x: string]: Schema.Json }; +export const V2GatewayOAuthLoginResponse = Schema.Record( + Schema.String, + Schema.Json.annotate({ expected: "JSON value" }), +).annotate({ title: "GatewayOAuthLoginResponse" }); + +export type V2GatewayOAuthReadResponse = { + readonly error?: string | null; + readonly providerId: string; + readonly providerName: string; + readonly required: boolean; + readonly status?: V2GatewayOAuthReadResponse__GatewayOAuthStatus | null; +}; +export const V2GatewayOAuthReadResponse = Schema.Struct({ + error: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + providerId: Schema.String, + providerName: Schema.String, + required: Schema.Boolean.annotate({ + description: "Whether the selected provider uses gateway OAuth, even when already signed in.", + }), + status: Schema.optionalKey( + Schema.Union([V2GatewayOAuthReadResponse__GatewayOAuthStatus, Schema.Null]).annotate({ + description: "Null when the effective provider does not use gateway OAuth.", + }), + ), +}).annotate({ + title: "GatewayOAuthReadResponse", + description: + "Current effective gateway policy and credential readiness; never contains credentials.", +}); + export type V2GetAccountParams = { readonly refreshToken?: boolean }; export const V2GetAccountParams = Schema.Struct({ refreshToken: Schema.optionalKey( @@ -52870,6 +53243,7 @@ export type V2ListMcpServerStatusParams = { readonly cursor?: string | null; readonly detail?: V2ListMcpServerStatusParams__McpServerStatusDetail | null; readonly limit?: number | null; + readonly serverName?: string | null; readonly threadId?: string | null; }; export const V2ListMcpServerStatusParams = Schema.Struct({ @@ -52902,6 +53276,15 @@ export const V2ListMcpServerStatusParams = Schema.Struct({ Schema.Null, ]), ), + serverName: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Limit discovery to one server. With a thread ID, reuse that thread's MCP connection.", + }), + Schema.Null, + ]), + ), threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }).annotate({ title: "ListMcpServerStatusParams" }); @@ -53129,6 +53512,7 @@ export type V2McpResourceReadParams = { readonly connectorId?: string | null; readonly originCallId?: string | null; readonly server: string; + readonly target?: V2McpResourceReadParams__McpResourceReadTarget | null; readonly threadId?: string | null; readonly uri: string; }; @@ -53143,6 +53527,11 @@ export const V2McpResourceReadParams = Schema.Struct({ ]), ), server: Schema.String, + target: Schema.optionalKey( + Schema.Union([V2McpResourceReadParams__McpResourceReadTarget, Schema.Null]).annotate({ + description: "Explicit hosted app/account. Omit to retain legacy resource discovery.", + }), + ), threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), uri: Schema.String, }).annotate({ title: "McpResourceReadParams" }); @@ -54530,7 +54919,7 @@ export const V2ThreadInjectItemsResponse = Schema.Record( ).annotate({ title: "ThreadInjectItemsResponse" }); export type V2ThreadItemsListParams = { - readonly cursor?: string | null; + readonly cursor?: V2ThreadItemsListParams__ThreadItemsListCursor | null; readonly limit?: number | null; readonly sortDirection?: V2ThreadItemsListParams__SortDirection | null; readonly threadId: string; @@ -54538,12 +54927,10 @@ export type V2ThreadItemsListParams = { }; export const V2ThreadItemsListParams = Schema.Struct({ cursor: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: "Opaque cursor to pass to the next call to continue after the last item.", - }), - Schema.Null, - ]), + Schema.Union([V2ThreadItemsListParams__ThreadItemsListCursor, Schema.Null]).annotate({ + description: + "Opaque continuation cursor or an exclusive item anchor in the requested visible turn. An item anchor requires a non-empty `turnId`; ascending (the default) returns items after it, and descending returns items before it. Continue with the returned string cursor.", + }), ), limit: Schema.optionalKey( Schema.Union([ From ff1db030b179ef712cacc0098366d976e2877f45 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:50:27 +0000 Subject: [PATCH 05/28] chore(release): prepare v0.0.44 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 088282c83859..86860a90b262 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.43", + "version": "0.0.44", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index e459781fa8e6..32d9e2e922b2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.43", + "version": "0.0.44", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 3f23a2e32b8a..92b30fba5c92 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.43", + "version": "0.0.44", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index d356c57c2375..c30951abc585 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.43", + "version": "0.0.44", "private": true, "files": [ "dist" From 8792f95762092ea33e74d41a07e8818befd84970 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 29 Sep 2026 19:56:09 -0300 Subject: [PATCH 06/28] test(server): stop pinning codex install advisory to a release range (#14323) --- apps/server/src/provider/CodexInstallation.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index 40199c676839..906d04bab699 100644 --- a/apps/server/src/provider/CodexInstallation.test.ts +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -281,7 +281,9 @@ it.effect("requires an update before running an older activated managed installa '{"version":"0.155.1"}', ); const restarted = yield* makeHarness({ baseDir: first.baseDir }); - expect((yield* Effect.flip(restarted.installation.resolve())).detail).toContain("0.156.0"); + expect((yield* Effect.flip(restarted.installation.resolve())).detail).toContain( + "outside the supported range", + ); yield* restarted.installation.start; expect((yield* terminalState(restarted.installation)).phase).toBe("succeeded"); expect((yield* restarted.installation.resolve()).version).toBe("0.156.1"); From 63b61e647c8d973e4378a360cbf576d914f5564d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 29 Sep 2026 17:01:49 -0700 Subject: [PATCH 07/28] chore: stop CodeRabbit from editing PR descriptions (#14307) --- .coderabbit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ccfeca966dfa..bf28b1b342d2 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,4 +1,5 @@ reviews: + high_level_summary: false review_status: false auto_review: enabled: true From 60cb7d180d9ebdd1010460ccd94cdfad1ec83042 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:02:37 -0700 Subject: [PATCH 08/28] fix(web): unresolved pull request links use the compact link tooltip (#14243) --- .../pullRequest/PullRequestLinkPreview.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index 76aaebbf62d1..3f183b264b2b 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -15,6 +15,7 @@ import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { useEnvironmentQuery } from "~/state/query"; import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestActorAvatar, resolvePullRequestState } from "./pullRequestPresentation"; interface PullRequestLinkPreviewTarget { @@ -81,6 +82,8 @@ export function PullRequestLinkPreview({ }) : link; const detail = detailQuery.data; + const showCard = detail !== null || (detailQuery.error !== null && fallback !== undefined); + const showUrlTooltip = open && detailQuery.error !== null && !showCard; const state = detail === null ? null @@ -94,16 +97,19 @@ export function PullRequestLinkPreview({ return ( - - {detail !== null || detailQuery.error !== null ? ( + + } + delay={350} + closeDelay={120} + /> + {originalUrl} + + {showCard ? (
{detail === null ? ( - (fallback ?? ( -

- {originalUrl} -

- )) + fallback ) : (
From 88fbc2cac77ff2bf0d406c44754ecd8014976f5f Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Tue, 29 Sep 2026 21:02:52 -0300 Subject: [PATCH 09/28] fix(client-runtime): keep model ids in inline code from becoming file chips (#13909) --- packages/client-runtime/src/markdownLinks.test.ts | 9 +++++++++ packages/client-runtime/src/markdownLinks.ts | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 4aea947b87d6..0a12f14dc6b8 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -22,6 +22,15 @@ describe("inlineCodeFilePathCandidate", () => { ["127.0.0.1:3000", null], ["example.com/index.html", null], ["example.pl/index.html", null], + ["z-ai/glm-5.3", null], + ["z-ai/glm-5.3:12", null], + ["python/3.12", null], + ["Qwen/Qwen2.5-Coder", null], + ["meta-llama/Llama-3.1-8B", null], + ["share/man/ls.1", "share/man/ls.1"], + ["usr/lib/libfoo.so.1", "usr/lib/libfoo.so.1"], + ["vendor/jquery-3.6.0.min.js", "vendor/jquery-3.6.0.min.js"], + ["./models/glm-5.3", "./models/glm-5.3"], ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => { expect(inlineCodeFilePathCandidate(source)).toBe(candidate); }); diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 29a337e49c42..a7fc6881c694 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -14,6 +14,9 @@ const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; +// A final dot between digits marks a version or model id (`glm-5.3`, +// `Qwen2.5-Coder`), not an extension. `ls.1` and `libfoo.so.1` stay files. +const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; // Standard OS and dev-container roots; deliberately excludes app-route-ish // prefixes like /app/ or /chat/ so SPA routes never read as files. @@ -176,6 +179,7 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { .replace(/[/\\]+$/, "") .split(/[\\/]/) .at(-1) ?? ""; + if (VERSION_SUFFIX_PATTERN.test(basename)) return null; if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null; } return candidate; From 916ec94f93da686303f878d506a760efd13506c1 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 30 Sep 2026 10:04:24 +1000 Subject: [PATCH 10/28] fix(web): show the agent's question on user-input timeline rows (#12900) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../components/chat/MessagesTimeline.test.tsx | 59 +++++++++++++++++-- .../src/components/chat/MessagesTimeline.tsx | 37 +++++++----- .../src/work-log/userInput.test.ts | 37 ++++++++++++ .../client-runtime/src/work-log/userInput.ts | 7 +++ 4 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 packages/client-runtime/src/work-log/userInput.test.ts diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a87dc2c2a444..b6e5f2a2927e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -361,28 +361,79 @@ describe("MessagesTimeline", () => { }); const questionToggle = renderer!.root.find( (node) => - node.props["aria-label"]?.startsWith("Question answer submitted:") && + node.props["aria-label"]?.startsWith("Provide a spec") && node.props["aria-expanded"] === false, ); expect(questionToggle.props["aria-label"]).toContain( Object.values(answers)[0] ?? "spec.txt", ); - expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); + // The question leads the collapsed row so the exchange reads as a + // question and answer without expanding (heading + accessible label). + expect(JSON.stringify(renderer!.toJSON()).match(/Provide a spec/g)).toHaveLength(2); await act(() => questionToggle.props.onClick()); const markup = JSON.stringify(renderer!.toJSON()); - expect(markup.match(/Provide a spec/g)).toHaveLength(1); + // Expanded, the question also appears in the history: label, heading, history. + expect(markup.match(/Provide a spec/g)).toHaveLength(3); expect(markup).toContain("spec.txt"); expect(markup).toContain("Provide a screenshot"); expect(markup).toContain("shot.png"); for (const answer of Object.values(answers)) expect(markup).toContain(answer); await act(() => questionToggle.props.onClick()); - expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); + // Collapsing hides the history but keeps the question heading. + expect(JSON.stringify(renderer!.toJSON())).toContain("Provide a spec"); } finally { await act(() => renderer?.unmount()); } }, ); + it("leads an unanswered question row with the question text", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + , + ); + }); + const questionToggle = renderer!.root.find( + (node) => + node.props["aria-label"] === "Which repository?" && node.props["aria-expanded"] === false, + ); + const markup = JSON.stringify(renderer!.toJSON()); + // Heading + accessible label. + expect(markup.match(/Which repository\?/g)).toHaveLength(2); + await act(() => questionToggle.props.onClick()); + // Expanded history adds a third occurrence alongside heading and label. + expect(JSON.stringify(renderer!.toJSON()).match(/Which repository\?/g)).toHaveLength(3); + } finally { + await act(() => renderer?.unmount()); + } + }); + it.each([ { toolLifecycleStatus: "inProgress", isAtEnd: true }, { toolLifecycleStatus: "inProgress", isAtEnd: false }, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bf7faa735fed..8ac769f33e7f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4,6 +4,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { getQuestionAnswerPreview, getQuestionAnswerText, + getQuestionTextPreview, hasQuestionAnswer, } from "@t3tools/client-runtime/work-log/user-input"; import { @@ -3262,7 +3263,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ); } - const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); + const questionHeading = row.entry.questionAnswer + ? getQuestionTextPreview(row.entry.questionAnswer) + : ""; + const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const failed = workEntryDisplayIndicatesToolFailure(row.entry); return ( @@ -3275,17 +3279,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract - {label} - + {label} + {getQuestionAnswerPreview(row.entry.questionAnswer)} @@ -4760,10 +4757,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { showWarningIndicator || showDestructiveRowStyle ? undefined : (workEntry.toolIcon ?? workEntry.toolSource?.icon); - const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); - const answerPreview = workEntry.questionAnswer - ? getQuestionAnswerPreview(workEntry.questionAnswer) - : null; + // The question is the row's identity: a generic "User input submitted" + // label buries what was asked, so lead with the question text and keep the + // answer as the trailing preview. + const questionHeading = workEntry.questionAnswer + ? getQuestionTextPreview(workEntry.questionAnswer) + : ""; + const previewText = + displayLabel ?? (questionHeading || workEntryDisplayLabel(workEntry, workspaceRoot)); + const answerPreview = + workEntry.questionAnswer && hasQuestionAnswer(workEntry.questionAnswer) + ? getQuestionAnswerPreview(workEntry.questionAnswer) + : null; const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = viewedImagePath && threadRef @@ -4860,7 +4865,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {

= {}, +): UserInputAttachmentAnswerPayload { + return { + requestId: ApprovalRequestId.make("request-1"), + questionTextById: { scope: "Which repository?" }, + answers: { scope: "Use the private repository" }, + attachmentsByQuestionId: {}, + ...overrides, + }; +} + +describe("getQuestionTextPreview", () => { + it("joins the question texts", () => { + expect( + getQuestionTextPreview( + answer({ questionTextById: { scope: "Which repository?", name: "What name?" } }), + ), + ).toBe("Which repository? · What name?"); + }); + + it("normalizes whitespace and skips blank texts", () => { + expect( + getQuestionTextPreview( + answer({ questionTextById: { scope: "Which\nrepository?", x: " " } }), + ), + ).toBe("Which repository?"); + }); + + it("returns an empty string without question texts", () => { + expect(getQuestionTextPreview(answer({ questionTextById: undefined }))).toBe(""); + }); +}); diff --git a/packages/client-runtime/src/work-log/userInput.ts b/packages/client-runtime/src/work-log/userInput.ts index 18dea7dd83c2..d1bb4370efbf 100644 --- a/packages/client-runtime/src/work-log/userInput.ts +++ b/packages/client-runtime/src/work-log/userInput.ts @@ -163,6 +163,13 @@ export function getQuestionAnswerText(value: unknown): string { return nested ? getQuestionAnswerText(nested.answers) : ""; } +export function getQuestionTextPreview(answer: UserInputAttachmentAnswerPayload): string { + return Object.values(answer.questionTextById ?? {}) + .map((text) => text.replace(/\s+/g, " ").trim()) + .filter(Boolean) + .join(" · "); +} + export function getQuestionAnswerPreview(answer: UserInputAttachmentAnswerPayload): string { const answers = Object.values(answer.answers).map(getQuestionAnswerText).filter(Boolean); const attachments = Object.values(answer.attachmentsByQuestionId) From 55ec55b1fb2e65f2736ff8420ae0373210c1911e Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 30 Sep 2026 10:05:44 +1000 Subject: [PATCH 11/28] fix(web): open workspace root links in the file explorer (#12449) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../components/files/FilePreviewPanel.test.ts | 22 +++++++++++++++++++ .../src/components/files/FilePreviewPanel.tsx | 12 ++++++++-- .../src/components/files/filePreviewMode.ts | 7 ++++++ apps/web/src/markdown-links.test.ts | 8 +++++++ apps/web/src/rightPanelStore.test.ts | 18 +++++++++++++++ apps/web/src/rightPanelStore.ts | 3 +++ apps/web/src/workspaceBasenameLookup.test.ts | 2 ++ apps/web/src/workspaceBasenameLookup.ts | 8 ++++++- .../client-runtime/src/markdownLinks.test.ts | 5 +++++ packages/client-runtime/src/markdownLinks.ts | 1 + 10 files changed, 83 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 5ef590847c4b..ebebf510994b 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -7,6 +7,7 @@ import { } from "./fileCommentAnnotations"; import { isMarkdownPreviewFile, + resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, } from "./filePreviewMode"; @@ -120,3 +121,24 @@ describe("setMarkdownTaskChecked", () => { expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown); }); }); + +describe("resolveFilePreviewPath", () => { + it.each([ + ["/repo/project", null], + ["/repo/project/", null], + [".", null], + [null, null], + ["/repo/project/src", "/repo/project/src"], + ["/repo/project/src/main.ts", "/repo/project/src/main.ts"], + ["src/main.ts", "src/main.ts"], + ["/repo/project-other", "/repo/project-other"], + ])("opens %s in the appropriate workspace surface", (path, expected) => { + const relativePath = resolveFilePreviewPath(path, "/repo/project"); + expect(relativePath).toBe(expected); + if (expected === null) { + expect( + shouldShowFileExplorer({ relativePath, explorerOpen: false, attachmentOpen: false }), + ).toBe(true); + } + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 9d389ec31bb8..e2d3a2b3c8ca 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -81,6 +81,7 @@ import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { isMarkdownPreviewFile, + resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, } from "./filePreviewMode"; @@ -907,7 +908,7 @@ export default function FilePreviewPanel({ environmentId, cwd, projectName, - relativePath, + relativePath: requestedPath, attachment, threadRef, composerDraftTarget, @@ -920,6 +921,8 @@ export default function FilePreviewPanel({ selectedFilePending, workspaceMutationId, }: FilePreviewPanelProps) { + const relativePath = + attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -945,7 +948,12 @@ export default function FilePreviewPanel({ // shown. The read still runs: a folder named `assets.png` is only knowable as a // folder from the read failure, and the server stats before reading, so a folder // costs an open and a stat and returns no body. - const file = useProjectFileQuery(environmentId, cwd, relativePath, attachment === undefined); + const file = useProjectFileQuery( + environmentId, + cwd, + relativePath, + attachment === undefined && relativePath !== null, + ); // A chat link cannot tell a folder from a file, so a folder arrives here as // a file surface and the read fails. Keep the breadcrumbs, drop the preview // pane, and let the tree fill the surface with the folder revealed. Mutation diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 9770d36fa2c3..12a3eb95a3e8 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,5 +1,12 @@ +import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links"; import { isAbsolutePath } from "~/terminal-links"; +/** Resolve workspace links before choosing between the explorer and a file preview. */ +export function resolveFilePreviewPath(path: string | null, cwd: string): string | null { + if (path === null) return null; + return path === "." || workspaceRelativeFilePath(path, cwd) === "." ? null : path; +} + export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); export function shouldShowFileExplorer(input: { diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5421c0d8781d..fe352ec1a44e 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -522,3 +522,11 @@ describe("directory paths with a trailing separator", () => { expect(meta?.basename).not.toBe(""); }); }); + +it("routes the project-root code link to the workspace explorer", () => { + const cwd = "/Users/saphid/.t3/worktrees/ov2-standalone-20260918"; + expect(resolveInlineCodeFileLinkMeta(cwd, cwd)).toMatchObject({ + workspaceRelativePath: ".", + filePath: cwd, + }); +}); diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 151c1699cbf7..c73a2a009c16 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -447,6 +447,24 @@ describe("rightPanelStore", () => { }); }); + it("opens workspace-root links as the singleton files explorer", () => { + const store = useRightPanelStore.getState(); + store.openFile(refA, "README.md"); + store.openFile(refA, "."); + store.openFile(refA, "."); + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.activeSurfaceId).toBe("files"); + expect(state.surfaces.map((surface) => surface.id)).toEqual(["file:README.md", "files"]); + store.closeSurface(refA, "files"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).activeSurfaceId, + ).toBe("file:README.md"); + store.openFile(refA, "."); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).activeSurfaceId, + ).toBe("files"); + }); + it("replaces the standalone explorer with peer file surfaces", () => { useRightPanelStore.getState().open(refA, "files"); useRightPanelStore.getState().openFile(refA, "src/index.ts"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index f020f14466b3..75f6d2ffe2b0 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -578,6 +578,9 @@ export const useRightPanelStore = create()( openFile: (ref, requestedPath, line) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { + if (requestedPath === ".") { + return upsertSurface(current, singletonSurface("files")); + } // Workspace entry paths use '/', including on Windows. const relativePath = /^[A-Za-z]:\/+$/.test(requestedPath) ? requestedPath diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index b4a1376e79c3..afe159dd402b 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -15,6 +15,8 @@ describe("needsWorkspaceBasenameLookup", () => { it("leaves anything with a directory alone", () => { expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false); expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup(".")).toBe(false); + expect(needsWorkspaceBasenameLookup("..")).toBe(false); expect(needsWorkspaceBasenameLookup(" ")).toBe(false); }); }); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index b99d3ba4ded9..0efdfcf3a44d 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -25,7 +25,13 @@ function basenameOfPath(path: string): string { export function needsWorkspaceBasenameLookup(relativePath: string): boolean { const trimmed = relativePath.trim(); - return trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\"); + return ( + trimmed !== "." && + trimmed !== ".." && + trimmed.length > 0 && + !trimmed.includes("/") && + !trimmed.includes("\\") + ); } export function pickWorkspaceBasenameMatch( diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 0a12f14dc6b8..deea6960478e 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -155,6 +155,11 @@ describe("fileBasename", () => { describe("workspaceRelativeFilePath", () => { it.each([ + ["/repo/project", "/repo/project", "."], + ["/repo/project/", "/repo/project/", "."], + ["/", "/", "."], + ["C:/USERS/mike/project", "c:/users/MIKE/project", "."], + ["C:/", "c:/", "."], ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"], ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"], ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index a7fc6881c694..0a32f5061083 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -340,6 +340,7 @@ export function workspaceRelativeFilePath( const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; + if (pathForCompare.replace(/\/+$/, "") === rootForCompare) return "."; if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; return normalizedPath.slice(normalizedRoot.length + 1); } From 050cfad04f673fb092630614b8f7e82000b43ea5 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 30 Sep 2026 10:09:03 +1000 Subject: [PATCH 12/28] fix(grok): recover from crashed provider sessions (#10607) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: SAPHID --- apps/server/scripts/acp-mock-agent.ts | 6 ++ .../src/provider/Layers/GrokAdapter.test.ts | 60 +++++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 37 ++++++++++-- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 17a464be2f10..4865354e41f5 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -621,6 +621,12 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); promptCount += 1; + if ( + process.env.T3_ACP_CRASH_PROMPT === "1" && + request.prompt.some((part) => part.type === "text" && part.text === "crash now") + ) { + return yield* Effect.sync(() => process.exit(23)); + } if (completeFirstPromptOnCancel && promptCount === 1) { yield* agent.client.sessionUpdate({ diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index ecd73af72dbe..fb17d6839fda 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -363,6 +363,66 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("retires a crashed process so a deliberate retry can resume", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-crash-recovery"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-crash-recovery-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapper = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_CRASH_PROMPT: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapper); + const exited = + yield* Deferred.make>(); + const events = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "session.exited" + ? Deferred.succeed(exited, event).pipe(Effect.asVoid) + : Effect.void, + ).pipe(Effect.forkChild); + const input = { + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access" as const, + }; + const session = yield* adapter.startSession(input); + const failure = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "crash now", attachments: [] }), + ); + assert.isDefined(failure); + assert.isFalse(yield* adapter.hasSession(threadId)); + const retryDuringTeardown = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "retry during teardown", attachments: [] }), + ); + assert.equal(retryDuringTeardown._tag, "ProviderAdapterSessionNotFoundError"); + assert.equal((yield* Deferred.await(exited)).payload.exitKind, "error"); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + yield* Fiber.interrupt(events); + yield* adapter.startSession({ ...input, resumeCursor: session.resumeCursor }); + const turn = yield* adapter.sendTurn({ threadId, input: "retry now", attachments: [] }); + assert.equal(turn.threadId, threadId); + yield* adapter.stopSession(threadId); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.equal(requests.filter((request) => request.method === "session/new").length, 1); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + const resumes = requests.filter((request) => request.method === "session/load"); + assert.equal(resumes.length, 1); + assert.deepStrictEqual(resumes[0]?.params, { + sessionId: "mock-session-1", + cwd: process.cwd(), + mcpServers: [], + }); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-mock-thread"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 601ce9ffa37e..63ff71eca257 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -175,6 +175,7 @@ interface GrokSessionContext { currentModelId: string | undefined; currentReasoningEffort: string | undefined; stopped: boolean; + terminated: boolean; /** Live monitor/shell identities and their originating turns. */ readonly backgroundTasks: Map; } @@ -345,6 +346,7 @@ export function grokPromptSettlementBelongsToContext(input: { export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { return Effect.gen(function* () { + const ownerScope = yield* Effect.scope; const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -922,7 +924,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: ThreadId, ): Effect.Effect => { const ctx = sessions.get(threadId); - if (!ctx || ctx.stopped) { + if (!ctx || ctx.stopped || ctx.terminated) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), ); @@ -946,7 +948,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(yield* makeEventStamp()), provider: PROVIDER, threadId: ctx.threadId, - payload: { exitKind: "graceful" }, + payload: { exitKind: ctx.terminated ? "error" : "graceful" }, }); }); @@ -1316,12 +1318,35 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) : currentStartReasoningEffort, stopped: false, + terminated: false, backgroundTasks: new Map(), }; const nf = yield* Stream.runDrain( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { + if (event._tag === "ConnectionTerminated") { + ctx.terminated = true; + yield* withThreadLock( + ctx.threadId, + Effect.gen(function* () { + if (sessions.get(ctx.threadId) !== ctx) return; + if (ctx.activeTurnId) { + yield* settlePromptInFlight( + ctx.threadId, + ctx.activeTurnId, + ctx.acpSessionId, + { + errorMessage: "Grok connection terminated.", + settleAllPrompts: true, + }, + ); + } + yield* stopSessionInternal(ctx); + }), + ).pipe(Effect.forkIn(ownerScope)); + return; + } if (event._tag === "EventStreamBarrier") { yield* Deferred.succeed(event.acknowledge, undefined); return; @@ -2165,12 +2190,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const listSessions: GrokAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + Effect.sync(() => + Array.from(sessions.values()) + .filter((c) => !c.terminated) + .map((c) => ({ ...c.session })), + ); const hasSession: GrokAdapterShape["hasSession"] = (threadId) => Effect.sync(() => { const c = sessions.get(threadId); - return c !== undefined && !c.stopped; + return c !== undefined && !c.stopped && !c.terminated; }); const stopAll: GrokAdapterShape["stopAll"] = () => From 2a23c30ea60854cb17bec1a9d248032207c5d6b2 Mon Sep 17 00:00:00 2001 From: Jaka <100130505+jakaskerjanc@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:20:22 +0200 Subject: [PATCH 13/28] fix(web): let command menu descriptions use the full row width (#8865) Co-authored-by: Claude Sonnet 5 Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/components/chat/ComposerCommandMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 4f0cabf8b4b4..739a09fecf13 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -197,7 +197,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { props.item.label )} - + {props.item.description} {skillSourceKind ? ( From 0fcd5f90611451cca842689faea53b5450c022da Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:40:55 -0700 Subject: [PATCH 14/28] fix(web): multi-PR badges open the linked pull requests panel (#13211) --- .../BranchToolbarBranchSelector.tsx | 2 +- apps/web/src/components/Sidebar.tsx | 7 +++--- .../src/components/ThreadStatusIndicators.tsx | 25 ++++++++++--------- docs/user/source-control.md | 2 +- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 17199bbf3374..c28bb4752811 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -793,7 +793,7 @@ export function BranchToolbarBranchSelector({ number={prNumber} url={prUrl} status={displayedPrStatus} - onOpenStack={() => useRightPanelStore.getState().open(threadRef, "pull-requests")} + onOpenList={() => useRightPanelStore.getState().open(threadRef, "pull-requests")} onOpenPullRequest={(event) => { if (prUrl) openPrLink(event, prUrl); }} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 90f65ea2b941..7b9e0e545d56 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1515,11 +1515,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const accessibleTitle = isRenaming ? null : {thread.title}; // Stacks show their layer count; multiple unrelated links show their total count. - // Plain clicks open T3; individual PR links also support opening the host in a new tab. + // Either opens the thread's pull requests tab; a single PR link opens that PR and still + // supports opening the host in a new tab. const prBadgeShape = supportsMultiplePullRequests ? resolveThreadPullRequestBadge(thread.pullRequests) : null; - const handlePrStackClick = useCallback(() => { + const handlePrListClick = useCallback(() => { useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); @@ -1531,7 +1532,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { number={pr?.number ?? currentLinkedPr?.number} url={pr?.url ?? currentLinkedPr?.url} status={prStatus} - onOpenStack={handlePrStackClick} + onOpenList={handlePrListClick} onOpenPullRequest={handlePrClick} /> ) : null; diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 859650ca0a9f..6ca601653f30 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -195,7 +195,8 @@ export function resolveThreadPullRequestBadgePresentation({ * The linked-PR badge shared by the sidebar and composer footer. The badge owns what it shows: * the state glyph and number at the meta size, in the state's color. The caller owns the control * it sits in through `render` (an inline link in a sidebar row, a toolbar control in the - * composer), and the badge fills in the link or stack button behavior. + * composer), and the badge fills in the behavior: a single PR is a link to it, while a stack or + * several linked PRs is a button that opens the thread's pull requests tab. */ export function ThreadPullRequestBadgeControl({ render, @@ -203,7 +204,7 @@ export function ThreadPullRequestBadgeControl({ number, url, status, - onOpenStack, + onOpenList, onOpenPullRequest, }: { render: ReactElement<{ render?: useRender.RenderProp }>; @@ -211,7 +212,7 @@ export function ThreadPullRequestBadgeControl({ number?: number | undefined; url?: string | undefined; status: PrStatusIndicator | null; - onOpenStack: () => void; + onOpenList: () => void; onOpenPullRequest: (event: MouseEvent) => void; }) { const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status }); @@ -220,9 +221,9 @@ export function ThreadPullRequestBadgeControl({ 0)} url={url} - onOpenStack={onOpenStack} + onOpenList={onOpenList} onOpenPullRequest={onOpenPullRequest} /> ); @@ -231,26 +232,26 @@ export function ThreadPullRequestBadgeControl({ function PullRequestBadge({ render, presentation, - isStack, + opensList, url, - onOpenStack, + onOpenList, onOpenPullRequest, }: { render: ReactElement<{ render?: useRender.RenderProp }>; presentation: NonNullable>; - isStack: boolean; + opensList: boolean; url: string | undefined; - onOpenStack: () => void; + onOpenList: () => void; onOpenPullRequest: (event: MouseEvent) => void; }) { - const onClick = isStack + const onClick = opensList ? (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); - onOpenStack(); + onOpenList(); } : onOpenPullRequest; - const element = isStack ? ( + const element = opensList ? ( + {scratchTargetEnvironmentId === null ? null : ( + + )}

diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index ea4741b80664..3d5b7bfd311d 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -2,13 +2,22 @@ import type { DraftId } from "~/composerDraftStore"; import { useComposerDraftStore } from "~/composerDraftStore"; import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { FolderPlusIcon } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; +import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; +import { shortcutLabelForCommand } from "~/keybindings"; +import { projectIconColorClassName } from "~/projectIconColors"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useScratchProject } from "~/hooks/useScratchProject"; import { useClientSettings } from "~/hooks/useSettings"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; -import { selectProjectGroupingSettings } from "~/logicalProject"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "~/logicalProject"; import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, @@ -32,6 +41,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { InlineButton } from "../ui/button"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +// Menu value for "No project"; real entries are keyed by logical project key. +const NO_PROJECT_VALUE = "no-project"; + interface DraftHeroHeadlineProps { readonly draftId: DraftId | null; readonly activeProjectRef: ScopedProjectRef | null; @@ -56,6 +68,8 @@ export function DraftHeroHeadline({ const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const environmentLabelById = useMemo( () => @@ -131,6 +145,85 @@ export function DraftHeroHeadline({ const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; + // The project that hosts threads without a project appears once, as the + // "No project" item, not as a project row. + const menuEntries = projectPickerEntries.filter( + ({ targetProject }) => + !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)), + ); + const activeProject = + activeProjectRef === null + ? null + : (projects.find( + (project) => + project.environmentId === activeProjectRef.environmentId && + project.id === activeProjectRef.projectId, + ) ?? null); + const scratchTargetEnvironmentId = scratchEnvironmentId( + activeProjectRef?.environmentId ?? primaryEnvironmentId, + ); + const scratchWorkspaceRoot = scratchWorkspaceRootFor(scratchTargetEnvironmentId); + const isScratchDraft = + activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRoot); + + // The picker can change the draft's target while the no-project home is + // still being opened; a stale continuation must not retarget it again. + const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId }); + useEffect(() => { + latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId }; + }, [activeProjectKey, scratchTargetEnvironmentId, draftId]); + // Project selection changes the target of the open draft in place. The + // prompt stays in the same composer session, so the sidebar only gets a + // draft row if the user later navigates away. + const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => { + if (!draftId) { + return; + } + latestTargetRef.current = { + draftId, + activeProjectKey: logicalProjectKey, + scratchTargetEnvironmentId: project.environmentId, + }; + const currentDraft = getComposerDraft(draftId); + setLogicalProjectDraftThreadId( + logicalProjectKey, + scopeProjectRef(project.environmentId, project.id), + draftId, + ); + if (!hasExplicitComposerModelSelection(currentDraft)) { + applyStickyState(draftId); + const environmentSettings = environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig?.settings; + const defaultModelSelection = environmentSettings + ? resolveProjectSettings(environmentSettings, project.id, project).settings + .defaultModelSelection + : project.defaultModelSelection; + if (defaultModelSelection) { + setModelSelection(draftId, defaultModelSelection, { + replaceOptions: true, + }); + } + } + }; + const startScratch = async (): Promise => { + if (scratchTargetEnvironmentId === null || isScratchDraft) { + return false; + } + const requested = { draftId, activeProjectKey, scratchTargetEnvironmentId }; + const project = await openScratchProject(scratchTargetEnvironmentId); + const latest = latestTargetRef.current; + if ( + !project || + latest.draftId !== requested.draftId || + latest.activeProjectKey !== requested.activeProjectKey || + latest.scratchTargetEnvironmentId !== requested.scratchTargetEnvironmentId + ) { + return false; + } + selectProject(project, deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)); + return true; + }; const projectSelector = shouldShowProjectMenu ? ( @@ -143,55 +236,49 @@ export function DraftHeroHeadline({ // mid-sentence and baffle screen-reader users. } + data-draft-project-trigger="" className="pointer-events-auto max-w-64 align-baseline" /> } > - {activeProjectDisplayName ?? "Choose a project"} + + {isScratchDraft ? "No project" : (activeProjectDisplayName ?? "Choose a project")} + - {activeProjectDisplayName ? ( + {activeProjectDisplayName && !isScratchDraft ? ( {activeProjectDisplayName} ) : null} { - const entry = projectEntryByKey.get(value as string); - if (!entry || value === activeProjectKey) { + if (value === NO_PROJECT_VALUE) { + void startScratch(); return; } - const project = entry.targetProject; - if (!draftId) { + const entry = projectEntryByKey.get(value as string); + if (!entry || value === activeProjectKey) { return; } - // Project selection changes the target of the open draft in - // place. The prompt stays in the same composer session, so the - // sidebar only gets a draft row if the user later navigates away. - const currentDraft = getComposerDraft(draftId); - setLogicalProjectDraftThreadId( - entry.group.projectKey, - scopeProjectRef(project.environmentId, project.id), - draftId, - ); - if (!hasExplicitComposerModelSelection(currentDraft)) { - applyStickyState(draftId); - const environmentSettings = environments.find( - (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings; - const defaultModelSelection = environmentSettings - ? resolveProjectSettings(environmentSettings, project.id, project).settings - .defaultModelSelection - : project.defaultModelSelection; - if (defaultModelSelection) { - setModelSelection(draftId, defaultModelSelection, { - replaceOptions: true, - }); - } - } + selectProject(entry.targetProject, entry.group.projectKey); }} > - {projectPickerEntries.map(({ group }) => { + {scratchWorkspaceRoot === null ? null : ( + + + {/* Boxed like ProjectFavicon so the label lines up with project rows. */} + + No project + + + )} + {menuEntries.map(({ group }) => { return ( @@ -214,7 +301,7 @@ export function DraftHeroHeadline({ ); })} - + {projectPickerEntries.length > 0 ? : null} New project @@ -235,24 +322,64 @@ export function DraftHeroHeadline({ // a complete sentence too. The project picker is a control rendered inline // in the h1; without an explicit label its widget state bleeds into the // announced phrase. - const headingLabel = hasResolvedProject - ? `What should we build in ${activeProjectDisplayName}?` - : canChooseProject - ? `${activeProjectDisplayName ?? "Choose a project"} to start` - : "Add a project to start"; + const headingLabel = isScratchDraft + ? "What should we work on?" + : hasResolvedProject + ? `What should we build in ${activeProjectDisplayName}?` + : canChooseProject + ? `${activeProjectDisplayName ?? "Choose a project"} to start` + : "Add a project to start"; + + // One click out of the project, phrased as the alternative to the question + // above it. Focus moves to the project picker once this line has gone. + const noProjectShortcut = shortcutLabelForCommand(keybindings, "chat.newWithoutProject"); + const orStartWithoutProject = + scratchWorkspaceRoot !== null && !isScratchDraft && (hasResolvedProject || canChooseProject) ? ( + + + void startScratch().then((started) => { + if (started) { + document.querySelector("[data-draft-project-trigger]")?.focus(); + } + }) + } + /> + } + > + or start without a project + + {noProjectShortcut ? {noProjectShortcut} : null} + + ) : null; return ( -

- {hasResolvedProject ? ( - <>What should we build in {projectSelector}? - ) : canChooseProject ? ( - <>{projectSelector} to start - ) : ( - <>Add a project to start +
+

+ {isScratchDraft ? ( + <>What should we work on? + ) : hasResolvedProject ? ( + <>What should we build in {projectSelector}? + ) : canChooseProject ? ( + <>{projectSelector} to start + ) : ( + <>Add a project to start + )} +

+ {/* Reserved whenever threads can skip a project, so the heading does not + move. Without a project, the picker moves here to choose one. */} + {scratchWorkspaceRoot === null ? null : ( +

+ {isScratchDraft ? projectSelector : orStartWithoutProject} +

)} -

+
); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 7729ce183754..791ebccfa932 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -49,13 +49,13 @@ describe("KeybindingsSettings.logic", () => { it("orders Usage bindings and command choices like the page", () => { const expected = [ "usage.cost", - "usage.open", "usage.tokens", "usage.limits", "usage.period.day", "usage.period.week", "usage.period.month", "usage.period.quarter", + "usage.open", ]; const bindings = DEFAULT_RESOLVED_KEYBINDINGS.toReversed(); expect(buildKeybindingRows(bindings, "usage").map((row) => row.command)).toEqual(expected); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a06628e5d505..a1f445b5db82 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -19,10 +19,24 @@ const usageCommandOrder = new Map( [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]), ); -function compareUsageCommands(left: KeybindingCommand, right: KeybindingCommand): number | null { - const leftIndex = usageCommandOrder.get(left); - const rightIndex = usageCommandOrder.get(right); - return leftIndex !== undefined && rightIndex !== undefined ? leftIndex - rightIndex : null; +const firstUsageCommand = METRIC_OPTIONS[0].command; + +/** + * Orders commands by `key`, except Usage page commands, which sort as one + * block in page order where the first of them would sort. A total order, so + * adding a binding elsewhere cannot reshuffle the Usage rows. + */ +function compareCommands( + left: KeybindingCommand, + right: KeybindingCommand, + key: (command: KeybindingCommand) => string, +): number { + const leftRank = usageCommandOrder.get(left); + const rightRank = usageCommandOrder.get(right); + if (leftRank !== undefined && rightRank !== undefined) return leftRank - rightRank; + return key(leftRank === undefined ? left : firstUsageCommand).localeCompare( + key(rightRank === undefined ? right : firstUsageCommand), + ); } export type KeybindingSource = "Default" | "Custom" | "Project"; @@ -215,9 +229,7 @@ export function buildKeybindingRows( }); rowsWithConflicts.sort((left, right) => { - const commandCompare = - compareUsageCommands(left.command, right.command) ?? - left.command.localeCompare(right.command); + const commandCompare = compareCommands(left.command, right.command, (command) => command); if (commandCompare !== 0) return commandCompare; return left.key.localeCompare(right.key); }); @@ -290,10 +302,7 @@ export function buildKeybindingCommandOptions( for (const binding of keybindings) { commands.add(binding.command); } - return [...commands].toSorted( - (left, right) => - compareUsageCommands(left, right) ?? commandLabel(left).localeCompare(commandLabel(right)), - ); + return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel)); } export function commandLabel(command: KeybindingCommand): string { diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts new file mode 100644 index 000000000000..0d25f3385855 --- /dev/null +++ b/apps/web/src/hooks/useScratchProject.ts @@ -0,0 +1,98 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { waitForProject } from "~/state/entities"; +import { useEnvironments } from "~/state/environments"; +import { projectEnvironment } from "~/state/projects"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useNewThreadHandler } from "./useHandleNewThread"; + +function reportScratchFailure(error: unknown) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start without a project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); +} + +/** + * Threads without a project live in the environment's scratch project, a + * plain folder the server owns (users see it as "No project"). The server + * creates it on first use; after that it is an ordinary project on the + * non-git path, and each thread gets its own subfolder. + */ +export function useScratchProject() { + const { environments } = useEnvironments(); + const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { reportFailure: false }); + const handleNewThread = useNewThreadHandler(); + + /** The scratch folder of a connected environment, or null when it offers none. */ + const scratchWorkspaceRootFor = useCallback( + (environmentId: EnvironmentId | null): string | null => { + const environment = environments.find((entry) => entry.environmentId === environmentId); + return environment?.connection.phase === "connected" + ? (environment.serverConfig?.scratchWorkspaceRoot ?? null) + : null; + }, + [environments], + ); + + // A thread without a project starts on the machine the user is working on, + // and only there. With no current machine (the hosted app with nothing + // open), it starts on the one machine that offers it, never a silent pick. + const scratchEnvironmentId = useCallback( + (current: EnvironmentId | null): EnvironmentId | null => { + if (current !== null) return scratchWorkspaceRootFor(current) !== null ? current : null; + const offering = environments.filter( + (entry) => scratchWorkspaceRootFor(entry.environmentId) !== null, + ); + return offering.length === 1 ? (offering[0]?.environmentId ?? null) : null; + }, + [environments, scratchWorkspaceRootFor], + ); + + /** Resolves to the scratch project once it is in this client's store. */ + const openScratchProject = useCallback( + async (environmentId: EnvironmentId): Promise => { + const result = await ensureScratch({ environmentId, input: {} }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + reportScratchFailure(squashAtomCommandFailure(result)); + } + return null; + } + // Drafts key off the project's stored path and settings, so wait for + // the create event to reach the store before targeting one. + return waitForProject(scopeProjectRef(environmentId, result.value.projectId)).catch( + (error: unknown) => { + reportScratchFailure(error); + return null; + }, + ); + }, + [ensureScratch], + ); + + const startScratchThread = useCallback( + async (environmentId: EnvironmentId) => { + const project = await openScratchProject(environmentId); + if (project) { + await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch( + reportScratchFailure, + ); + } + }, + [handleNewThread, openScratchProject], + ); + + return { scratchWorkspaceRootFor, scratchEnvironmentId, openScratchProject, startScratchThread }; +} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 6a82b920ab31..2dae445041bb 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -19,6 +19,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -399,12 +400,18 @@ export function useThreadActions() { const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; + const environmentConfig = appAtomRegistry + .get(environmentServerConfigsAtom) + .get(threadRef.environmentId); + // A Scratch thread's folder is not a git worktree, and deleting the + // thread keeps its files. + const canDeleteWorktree = + orphanedWorktreePath !== null && + threadProject !== null && + !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot); const localApi = readLocalApi(); let shouldDeleteWorktree = false; - const environmentSettings = appAtomRegistry - .get(environmentServerConfigsAtom) - .get(threadRef.environmentId)?.settings; + const environmentSettings = environmentConfig?.settings; const automaticWorktreeCleanup = environmentSettings ? resolveWorktreeCleanup(environmentSettings, thread.projectId).worktreeOnDelete : false; diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 5b5b4888269b..0a0b1adee06b 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -13,6 +13,7 @@ import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useScratchProject } from "../hooks/useScratchProject"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; @@ -37,6 +38,7 @@ function ChatRouteGlobalShortcuts() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { scratchEnvironmentId, startScratchThread } = useScratchProject(); const projectGroupCount = useMemo( () => buildSidebarProjectSnapshots({ @@ -105,6 +107,17 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "chat.newWithoutProject") { + const environmentId = scratchEnvironmentId( + activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId, + ); + if (environmentId === null) return; + event.preventDefault(); + event.stopPropagation(); + void startScratchThread(environmentId); + return; + } + if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); @@ -180,9 +193,12 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, + primaryEnvironmentId, projectGroupCount, routeThreadRef, + scratchEnvironmentId, selectedThreadKeysSize, + startScratchThread, legacySidebarEnabled, terminalOpen, ]); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 2525fed64d94..1844eb62c211 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -122,7 +122,8 @@ through the pages you have visited, like a browser's back and forward buttons. `chat.new` may ask you to choose a project when there is more than one. `chat.newLocal` skips that chooser. Both use your -[new-thread defaults](./thread-sidebar.md#start-a-thread). +[new-thread defaults](./thread-sidebar.md#start-a-thread). `chat.newWithoutProject` +(`mod+alt+n`) starts a thread [without a project](./thread-sidebar.md#start-without-a-project). ## Reserved shortcuts diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4a19bac401b9..0335856145d3 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -13,6 +13,22 @@ an existing worktree, use **New thread in this worktree** from the branch toolba When you change a new thread's project, T3 Code stays in the current environment if that project exists there. Otherwise it selects an environment that has it. +### Start without a project + +A thread does not need a project. To start one without a project, click **or +start without a project** under a new thread's heading, pick **No project** from +the project menu in that heading or from **New thread in...** in the command +palette, or press `mod+alt+n`. On mobile, pick **No project** from the project +list. To move a draft into a project, pick the project in the heading. + +Each thread without a project works in its own folder under `~/.t3/scratch` (the +`scratch` folder of your T3 data directory), named after its date, the first words +of its first message, and a short id, like +`2026-09-25-convert-these-pngs-to-webp-a1b2c3d4`. Deleting a thread keeps its +folder, so the files the agent wrote stay until you delete them. Branch, worktree, and diff controls stay hidden because +these folders are not Git repositories. This is unavailable when the data +directory itself sits inside a Git checkout. + ### Start in the background In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter` diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc321547..d969a8c0d379 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -92,6 +92,13 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: projectConcurrency, }), + // Finds or creates the environment's Scratch project and returns its id. + ensureScratch: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:ensure-scratch", + tag: WS_METHODS.projectsEnsureScratch, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/client-runtime/src/state/projects.ts b/packages/client-runtime/src/state/projects.ts index 10ec5025fb9d..36b1f033ac36 100644 --- a/packages/client-runtime/src/state/projects.ts +++ b/packages/client-runtime/src/state/projects.ts @@ -135,6 +135,16 @@ export function findProjectByPath()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6e436a46db5d..215f3777a175 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -159,6 +159,7 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, ProjectListEntriesResult, @@ -293,6 +294,7 @@ export const WS_METHODS = { projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + projectsEnsureScratch: "projects.ensureScratch", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -991,6 +993,13 @@ const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), }); +// Finds or creates the Scratch project rooted at ServerConfig.scratchWorkspaceRoot. +const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { + payload: Schema.Struct({}), + success: ProjectEnsureScratchResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1508,6 +1517,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsReadFileRpc, WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, + WsProjectsEnsureScratchRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 0c2498ef6471..49faaaf44712 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -611,6 +611,12 @@ export const ServerConfig = Schema.Struct({ threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), /** Whether thread reads accept the reasoningMessages opt-in. */ reasoningMessages: Schema.optionalKey(Schema.Boolean), + /** + * Folder behind this environment's Scratch project, for threads that need + * no repository. Present only on servers that answer projects.ensureScratch + * and whose data dir is outside a Git checkout. + */ + scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), /** * Palettes published by this environment's machine. Never sent in a config * snapshot: the theme stream emits the current set before any change, so a diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8029be96f939..a93409bb3cb4 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -49,6 +49,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, From 9da066dbe906310671b86290b09df0b56a88ffa5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 30 Sep 2026 17:30:52 -0700 Subject: [PATCH 25/28] fix(server): Claude /compact no longer ends early and leaves the thread busy (#14497) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 67 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 33 +++++++++ 2 files changed, 100 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 7917b8360946..f00c58d96353 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1557,6 +1557,73 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps a turn open past the result of a Claude-initiated turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/compact", + attachments: [], + }); + + // Recorded order after a resume: Claude first reports a background task + // the previous process left behind, then runs the queued `/compact`. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + num_turns: 0, + origin: { kind: "task-notification" }, + session_id: "sdk-session-1", + uuid: "result-task-notification", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { trigger: "manual", pre_tokens: 959489, post_tokens: 10107 }, + session_id: "sdk-session-1", + uuid: "compact-boundary", + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + num_turns: 0, + user_message_uuid: turn.turnId, + user_message_uuids: [turn.turnId], + local_command: "compact", + session_id: "sdk-session-1", + uuid: "result-compact", + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const compactedIndex = runtimeEvents.findIndex( + (event) => event.type === "thread.state.changed" && event.payload.state === "compacted", + ); + const completedIndex = runtimeEvents.findIndex((event) => event.type === "turn.completed"); + assert.equal(runtimeEvents.filter((event) => event.type === "turn.completed").length, 1); + assert.equal(String(runtimeEvents[completedIndex]?.turnId), String(turn.turnId)); + assert.equal(String(runtimeEvents[compactedIndex]?.turnId), String(turn.turnId)); + assert.isAbove(completedIndex, compactedIndex); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => { 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 d50b3cbb9248..d971566217e6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1692,6 +1692,27 @@ function isOverloadedResult(result: SDKResultMessage): boolean { return result.subtype === "success" && result.api_error_status === 529; } +/** + * True when a result answers a different Claude turn than the active real + * turn. Claude runs turns of its own between user prompts (a resumed session + * first reports background tasks the previous process left behind; peer + * messages wake the agent), and a queued prompt waits behind them. Real turns + * send their turn id as the prompt uuid, which newer CLIs echo in + * `user_message_uuids`. Claude-initiated turns echo nothing and carry a + * non-human `origin`. Results with neither field (older CLIs) still complete + * the active turn. + */ +function isResultForOtherTurn(result: SDKResultMessage, turn: ClaudeTurnState): boolean { + // A synthetic turn mirrors a Claude-initiated turn, so any result is its own. + if (turn.synthetic) return false; + const echoed = [ + ...(result.user_message_uuids ?? []), + ...(result.user_message_uuid ? [result.user_message_uuid] : []), + ]; + if (echoed.length > 0) return !echoed.includes(turn.turnId); + return result.origin !== undefined && result.origin.kind !== "human"; +} + /** Derives turn status and its error from the same provider result. */ function resultOutcome( result: SDKResultMessage, @@ -3486,6 +3507,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const turn = context.turnState; + if (turn && isResultForOtherTurn(message, turn)) { + // Completing here would end the user's turn before its prompt runs. A + // `/compact` would then compact with no turn open and leave the thread + // looking busy. + yield* Effect.logInfo("claude.turn.result-for-other-turn", { + threadId: context.session.threadId, + turnId: turn.turnId, + origin: message.origin?.kind, + numTurns: message.num_turns, + }); + return; + } const failureHint = turn?.authenticationFailureMessage ?? (turn && (turn.rejectedRateLimitTypes.size > 0 || turn.latestAssistantRateLimited) From 1905846e03226582f64fec16e1500ddffa151fdf Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:49:15 -0700 Subject: [PATCH 26/28] fix(web): Dark+ and Light+ themes import instead of colliding with built-in ids (#14499) --- apps/web/src/vscodeThemeImport.test.ts | 30 ++++++++++++++++++++++++++ apps/web/src/vscodeThemeImport.ts | 13 ++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index 59f732bf4a12..797346a6557b 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -112,6 +112,36 @@ describe("VS Code theme import", () => { ); }); + it("moves reserved VS Code names to a non-reserved id", () => { + const darkPlus = parseVsCodeThemeFile({ + name: "Dark+", + type: "dark", + colors: { "editor.background": "#1e1e1e" }, + }); + expect(darkPlus.label).toBe("Dark+"); + expect(darkPlus.id).not.toBe("dark"); + expect(darkPlus.id).toBe("dark-vscode"); + }); + + it("keeps Dark+ and Light+ as separate themes instead of pairing them as +", () => { + const themes = pairVsCodeThemes([ + parseVsCodeThemeFile({ + name: "Light+", + type: "light", + colors: { "editor.background": "#ffffff" }, + }), + parseVsCodeThemeFile({ + name: "Dark+", + type: "dark", + colors: { "editor.background": "#1e1e1e" }, + }), + ]); + expect(themes.map((theme) => [theme.id, theme.label])).toEqual([ + ["light-vscode", "Light+"], + ["dark-vscode", "Dark+"], + ]); + }); + it("fills every role the file omits with a readable derived value", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); const colors = getThemeColorsForMode(theme, "dark")!; diff --git a/apps/web/src/vscodeThemeImport.ts b/apps/web/src/vscodeThemeImport.ts index 186e03fb6a34..fc0fc2bf8cbc 100644 --- a/apps/web/src/vscodeThemeImport.ts +++ b/apps/web/src/vscodeThemeImport.ts @@ -1,8 +1,10 @@ import { createVividThemeColors, getThemeModes, + isReservedThemeId, parseThemeFile, themeColorToHex, + themeIdFromName, THEME_FILE_VERSION, type ThemeAppearance, type ThemeColorRole, @@ -321,9 +323,12 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { // Reuse the theme-file parser so ids, names, and color values go through the // same validation as a hand-written file. + const name = resolveName(value); + const generatedId = themeIdFromName(name); return parseThemeFile({ version: THEME_FILE_VERSION, - name: resolveName(value), + ...(isReservedThemeId(generatedId) ? { id: `${generatedId}-vscode` } : {}), + name, appearance, colors: { ...derived, ...overrides }, }); @@ -352,9 +357,11 @@ export function pairVsCodeThemes( const passthrough: Array<{ theme: ThemeDefinition; order: number }> = []; themes.forEach((theme, order) => { // Only single-appearance themes with an appearance word in the name can - // pair; anything else is already what the user asked for. + // pair, and only when the rest of the name can still identify the pair + // ("Dark+" and "Light+" would pair as "+"); anything else is already + // what the user asked for. const key = stripAppearance(theme.label); - if (getThemeModes(theme).length !== 1 || key === theme.label || key.length === 0) { + if (getThemeModes(theme).length !== 1 || key === theme.label || !/[a-z0-9]/i.test(key)) { passthrough.push({ theme, order }); return; } From 0cf482b08bca0249ec94fbcbd0eb04fff28d9b3d Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Wed, 30 Sep 2026 20:54:00 -0400 Subject: [PATCH 27/28] fix(web): make composer suggestions usable with screen readers (#10154) Co-authored-by: Khai Shern, Toh Co-authored-by: Claude Opus 5 (1M context) --- .../components/ComposerPromptEditorTiptap.tsx | 26 +++++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 36 +++++++++++++++++-- .../chat/ComposerCommandMenu.test.tsx | 29 ++++++++++++++- .../components/chat/ComposerCommandMenu.tsx | 23 +++++++++++- .../chat/composerMenuHighlight.test.ts | 17 +++++++++ .../settings/SettingsFontPreviews.tsx | 1 + 6 files changed, 126 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 2a5fe2ba0588..c8866e7fc413 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -121,6 +121,11 @@ export interface ComposerPromptEditorProps { skills: ReadonlyArray; disabled: boolean; placeholder: string; + ariaLabel?: string | undefined; + /** Identifies an editor with suggestions, even while its list is closed. */ + suggestionListId?: string | undefined; + /** References the highlighted option only while its list is rendered. */ + activeSuggestionId?: string | undefined; containerClassName?: string; className?: string; placeholderClassName?: string; @@ -592,6 +597,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { skills, disabled, placeholder, + ariaLabel, + suggestionListId, + activeSuggestionId, containerClassName, className, placeholderClassName, @@ -752,9 +760,25 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ), "data-testid": "composer-editor", "data-composer-rich-text": richText ? "true" : "false", + role: "textbox", + "aria-multiline": "true", + ...(ariaLabel ? { "aria-label": ariaLabel } : {}), + ...(disabled ? { "aria-readonly": "true" } : {}), + ...(!disabled && suggestionListId + ? { + "aria-autocomplete": "list", + "aria-haspopup": "listbox", + ...(activeSuggestionId + ? { + "aria-controls": suggestionListId, + "aria-activedescendant": activeSuggestionId, + } + : {}), + } + : {}), "aria-placeholder": placeholder, }), - [className, placeholder, richText], + [activeSuggestionId, ariaLabel, className, disabled, placeholder, richText, suggestionListId], ); const editor = useEditor( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d168c6db4e5f..f0383dbaf17b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -58,6 +58,7 @@ import { type ReactNode, useCallback, useEffect, + useId, useImperativeHandle, useLayoutEffect, useMemo, @@ -245,7 +246,11 @@ import { useEnvironmentQuery } from "~/state/query"; import { useDebouncedValue } from "~/state/queries"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; -import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; +import { + type ComposerCommandItem, + ComposerCommandMenu, + composerSuggestionOptionId, +} from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ComposerImageThumbnail } from "./ComposerImageThumbnail"; @@ -2108,6 +2113,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) resetTrigger: resetComposerTrigger, } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length)); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const composerSuggestionId = useId(); + const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`; // Active ArrowUp recall. Cleared on edit and on thread switch. const promptHistoryPositionRef = useRef(null); const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( @@ -2502,7 +2509,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger - ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` + ? `${composerSuggestionListId}:${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` : null; const activeComposerMenuItem = useMemo(() => { const activeItemId = resolveComposerMenuActiveItemId({ @@ -2529,6 +2536,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const isComposerApprovalState = activePendingApproval !== null; + const composerSuggestionsVisible = composerMenuOpen && !isComposerApprovalState; + const composerSuggestionListVisible = composerSuggestionsVisible && composerMenuItems.length > 0; const activePendingUserInput = pendingUserInputs[0] ?? null; const isChoiceOnlyPendingQuestion = activePendingProgress?.activeQuestion?.allowCustomAnswer === false; @@ -3176,6 +3185,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ useEffect(() => { setComposerHighlightedItemId(null); + setComposerHighlightedSearchKey(null); setComposerSubmissionError(null); setProviderInputSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); @@ -6454,9 +6464,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} - {composerMenuOpen && !isComposerApprovalState && ( +
+ {composerSuggestionsVisible && composerMenuItems.length === 0 + ? isComposerMenuLoading + ? composerTriggerKind === "pull-request" + ? "Finding pull request..." + : "Searching workspace files..." + : composerMenuEmptyState + : ""} +
+ {composerSuggestionsVisible && ( { + it("keeps whitespace, escape-like paths, and malformed UTF-16 distinct", () => { + const paths = [ + "docs/my file.md", + "docs/my_file.md", + "docs/my%20file.md", + "docs/my\tfile.md", + "docs/\ud800.md", + "docs/\ud801.md", + "docs/\udc00.md", + "docs/\ufffd.md", + "docs/\\ud800.md", + "docs/\ud83d\ude80.md", + ]; + const ids = paths.map((path) => composerSuggestionOptionId("suggestions", `path:file:${path}`)); + + expect(new Set(ids).size).toBe(paths.length); + for (const id of ids) expect(id).not.toMatch(/\s|[\ud800-\udfff]/u); + expect(composerSuggestionOptionId("other-composer", paths[0]!)).not.toBe( + composerSuggestionOptionId("suggestions", paths[0]!), + ); + }); +}); describe("ComposerCommandMenu", () => { it("renders slash commands with their descriptions", () => { const markup = renderToStaticMarkup( { it("shows the app source for an app skill", () => { const markup = renderToStaticMarkup( { it("shows the repo source for a slash skill", () => { const markup = renderToStaticMarkup( {props.items.length > 0 ? ( - + {props.items.map((item) => ( } + aria-selected={props.isActive} value={props.item.id} data-composer-item-id={props.item.id} active={props.isActive} @@ -211,6 +220,18 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { ); }); +export function composerSuggestionOptionId(listId: string, itemId: string): string { + // JSON escapes lone UTF-16 surrogates before URI encoding without losing identity. + return `${listId}-${encodeURIComponent(JSON.stringify(itemId))}`; +} + +const LISTBOX_LABEL_BY_TRIGGER: Record = { + path: "Files and folders", + "pull-request": "Pull requests", + "slash-command": "Commands", + skill: "Skills", +}; + const SKILL_SOURCE_ICON_BY_KIND: Record = { app: BlocksIcon, repo: FolderIcon, diff --git a/apps/web/src/components/chat/composerMenuHighlight.test.ts b/apps/web/src/components/chat/composerMenuHighlight.test.ts index 549ad6bb09e8..74768d906878 100644 --- a/apps/web/src/components/chat/composerMenuHighlight.test.ts +++ b/apps/web/src/components/chat/composerMenuHighlight.test.ts @@ -48,4 +48,21 @@ describe("resolveComposerMenuActiveItemId", () => { }), ).toBe("top"); }); + + it("clears the active result while async results are empty and resolves against restored results", () => { + const search = { + highlightedItemId: "second", + currentSearchKey: "path:src", + highlightedSearchKey: "path:src", + }; + const cleared = resolveComposerMenuActiveItemId({ ...search, items: [] }); + expect(cleared).toBeNull(); + expect( + resolveComposerMenuActiveItemId({ + ...search, + highlightedItemId: cleared, + items: [{ id: "new-result" }, { id: "second" }], + }), + ).toBe("new-result"); + }); }); diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 6f392598b9e7..4715583e4005 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -39,6 +39,7 @@ export function PromptFontPreview() { return (
Date: Wed, 30 Sep 2026 21:43:41 -0500 Subject: [PATCH 28/28] fix(sync): retain RTVision integrations for release 0.0.66 Re-apply the fork overlay to the files that conflicted with upstream: - usageLimits: keep the Codex workspace accountId key and fall back to upstream's credential fingerprint when an account reports no email. - ws.ts getConfig: report both lowDiskSpace and scratchWorkspaceRoot. - Icons: keep NeovimIcon next to upstream's UltrafastIcon. - WSL install tests: take upstream's holdRuntimeBusy/holdInstallLock fixtures in place of the fork's `sleep 30; :` workaround. - .coderabbit.yaml: keep request_changes_workflow beside high_level_summary. - Package versions stay at the fork's 0.0.65. Co-Authored-By: Claude Opus 5.5 (1M context) --- .coderabbit.yaml | 1 + apps/desktop/package.json | 2 +- .../src/wsl/DesktopWslEnvironment.test.ts | 47 +++++++++- apps/server/package.json | 4 +- apps/server/src/ws.ts | 80 +++++++++++++++- apps/web/package.json | 3 +- apps/web/src/components/Icons.tsx | 42 +++++++++ packages/contracts/package.json | 2 +- packages/shared/src/usageLimits.ts | 93 ++++++++++++++----- 9 files changed, 241 insertions(+), 33 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index bf28b1b342d2..ec91f71bfedc 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,4 +1,5 @@ reviews: + request_changes_workflow: true high_level_summary: false review_status: false auto_review: diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 86860a90b262..b73980a2869a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.44", + "version": "0.0.65", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index e261524f9ac9..6fed9c2a82db 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -24,6 +24,7 @@ import { parseToolchainReport, parseWslRuntimeRoot, probeWslDistros, + resolveWslRunningUser, } from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); @@ -123,6 +124,32 @@ const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exit ), ); +describe("WSL account resolution", () => { + it.effect("uses a non-login shell and rejects malformed or failed identity output", () => + Effect.gen(function* () { + for (const [stdout, exitCode, expected] of [ + ["alice\n", 0, "alice"], + ["runningUser:root\nalice\n", 0, null], + ["alice\n", 1, null], + ["", 0, null], + ] as const) { + const delegate = makeDistroListSpawner({ stdout, exitCode }); + const spawner = ChildProcessSpawner.make((command) => { + expect(command._tag).toBe("StandardCommand"); + if (command._tag === "StandardCommand") { + expect(command.args).toEqual(["-d", "Debian", "--exec", "sh", "-s"]); + } + return delegate.spawn(command); + }); + const user = yield* resolveWslRunningUser("Debian").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + expect(user).toBe(expected); + } + }), + ); +}); + describe("probeWslDistros", () => { it.effect("preserves a successful empty distro list", () => Effect.gen(function* () { @@ -189,7 +216,7 @@ describe("WSL runtime cache", () => { expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); expect(script).toContain(' [ -f "$ready_marker" ] &&'); - expect(script).toContain(' runtime_entry_runs "$runtime_root" &&'); + expect(script).toContain(' runtime_entry_runs "$runtime_root" 2>/dev/null &&'); expect(script).toContain("if runtime_is_ready; then"); expect(script).not.toContain("bin.mjs"); expect(script).not.toContain("node-pty"); @@ -284,7 +311,7 @@ describe("WSL runtime cache", () => { // The same proof the SSH runner and CLI installers use: executable, and // `--version` exits 0. That is what decides arch and loadability, so no // separate native probe is needed. - expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1'); + expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null'); // Readiness gates the short-circuit, so a cache whose executable broke // reinstalls from the archive instead of being reused forever. @@ -424,7 +451,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed fixtures.length = 0; }); - const createFixture = () => { + const createFixture = (serverEntry = SERVER_ENTRY_SOURCE) => { const result = runShell( [ "set -eu", @@ -433,7 +460,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed // holds the executable and its native addons. 'stage="$work/stage/t3-0.0.0-linux-x64"', 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"', - `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`, + `printf '%s' ${sh(serverEntry)} > "$stage/t3"`, 'chmod +x "$stage/t3"', `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/build/Release/pty.node"`, `tar -czf "$work/wsl-runtime.tar.gz" -C "$work/stage" t3-0.0.0-linux-x64`, @@ -551,6 +578,18 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); }); + it("retains the loader error when a bundled executable needs a missing system library", () => { + const fixture = createFixture( + '#!/bin/sh\nprintf "error while loading shared libraries: libatomic.so.1: cannot open shared object file\\n" >&2\nexit 127\n', + ); + + const result = fixture.install(); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("libatomic.so.1"); + expect(parseWslRuntimeRoot(result.stdout)).toBeNull(); + }); + it("reinstalls a cache whose executable was truncated", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); diff --git a/apps/server/package.json b/apps/server/package.json index 32d9e2e922b2..259079469a7c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.44", + "version": "0.0.65", "license": "MIT", "repository": { "type": "git", @@ -16,7 +16,7 @@ "type": "module", "scripts": { "dev": "node --watch src/bin.ts", - "build:bundle": "vp pack", + "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean", "build:exe": "node scripts/cli.ts build-exe", "start": "node dist/bin.mjs", "typecheck": "tsc --noEmit", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 65923773907c..06cf94650c7b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -89,6 +89,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; +import * as DiskSpace from "./diskSpace.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -169,6 +170,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; +import * as GiteaCli from "./sourceControl/GiteaCli.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; @@ -552,6 +554,7 @@ const makeWsRpcLayer = ( const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const diskSpace = yield* DiskSpace.DiskSpace; const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; @@ -2032,6 +2035,7 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const lowDiskSpace = yield* diskSpace.current; const scratchWorkspaceRoot = yield* resolveScratchWorkspaceRoot; const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( externalLauncher.resolveAvailableEditors(), @@ -2050,6 +2054,7 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, + ...(lowDiskSpace === null ? {} : { lowDiskSpace }), availableEditors, // Same discovery-with-timeout treatment as editors: a slow probe // must not stall server.getConfig, so it degrades to no targets. @@ -3026,6 +3031,30 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "pull-requests" }, ), + [WS_METHODS.pullRequestsCiRuns]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsCiRuns, + withPullRequestViewer(input, pullRequests.ciRuns(input)), + { + projectId: input.projectId, + }, + ), + [WS_METHODS.pullRequestsCiJobs]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsCiJobs, + withPullRequestViewer(input, pullRequests.ciJobs(input)), + { + projectId: input.projectId, + }, + ), + [WS_METHODS.pullRequestsRerunCi]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsRerunCi, + withPullRequestViewer(input, pullRequests.rerunCi(input)), + { + projectId: input.projectId, + }, + ), [WS_METHODS.pullRequestsDetail]: (input) => observeRpcEffect( WS_METHODS.pullRequestsDetail, @@ -3034,6 +3063,11 @@ const makeWsRpcLayer = ( "rpc.aggregate": "pull-requests", }, ), + [WS_METHODS.pullRequestsDependencyContext]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsDependencyContext, + withPullRequestViewer(input, pullRequests.dependencyContext(input)), + ), [WS_METHODS.pullRequestsPreview]: (input) => observeRpcEffect( WS_METHODS.pullRequestsPreview, @@ -3126,6 +3160,22 @@ const makeWsRpcLayer = ( withPullRequestViewer(input, pullRequests.replyToThread(input)), { "rpc.aggregate": "pull-requests" }, ), + [WS_METHODS.pullRequestsViewedFiles]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsViewedFiles, + withPullRequestViewer(input, pullRequests.viewedFiles(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), + [WS_METHODS.pullRequestsSetFileViewed]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsSetFileViewed, + withPullRequestViewer(input, pullRequests.setFileViewed(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.pullRequestsSetThreadResolution]: (input) => observeRpcEffect( WS_METHODS.pullRequestsSetThreadResolution, @@ -3158,10 +3208,12 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "pull-requests" }, ), - [WS_METHODS.pullRequestsSubscribeRefreshes]: () => + [WS_METHODS.pullRequestsSubscribeRefreshes]: (input) => observeRpcStream( WS_METHODS.pullRequestsSubscribeRefreshes, - pullRequests.subscribeRefreshes, + pullRequests.subscribeRefreshes.pipe( + Stream.map((event) => (input.scoped ? event : event.revision)), + ), { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsReviewerCandidates]: (input) => @@ -3424,6 +3476,7 @@ const makeWsRpcLayer = ( const path = yield* Path.Path; // An absolute media path can be linked from a thread on another environment. if ( + input.resource._tag === "source-control-image" || input.resource._tag === "attachment" || input.resource._tag === "native-app-icon" || // GitHub media names the repository it authenticates through itself. @@ -3915,6 +3968,23 @@ const makeWsRpcLayer = ( })), ) : Stream.empty; + // Same gate again. The snapshot already carries the current + // report, so only changes after it go out. + const lowDiskSpaceUpdates = + input.lowDiskSpace === true + ? Stream.concat( + Stream.make(config.lowDiskSpace ?? null), + diskSpace.streamChanges, + ).pipe( + Stream.changesWith(DiskSpace.sameDiskSpaceReport), + Stream.drop(1), + Stream.map((lowDiskSpace) => ({ + version: 1 as const, + type: "lowDiskSpaceUpdated" as const, + payload: { lowDiskSpace }, + })), + ) + : Stream.empty; const settingsUpdates = serverSettings.streamChanges.pipe( Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.map((settings) => ({ @@ -3930,7 +4000,10 @@ const makeWsRpcLayer = ( providerStatuses, Stream.merge( settingsUpdates, - Stream.merge(environmentThemeUpdates, usageLimitSourceUpdates), + Stream.merge( + environmentThemeUpdates, + Stream.merge(usageLimitSourceUpdates, lowDiskSpaceUpdates), + ), ), ), ); @@ -4106,6 +4179,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, + GiteaCli.layer, GitHubCli.layer, GitLabCli.layer, ForgejoCli.layer, diff --git a/apps/web/package.json b/apps/web/package.json index 92b30fba5c92..17718e23805a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.44", + "version": "0.0.65", "private": true, "type": "module", "scripts": { @@ -26,6 +26,7 @@ "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", + "@shikijs/langs": "4.2.0", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 246eb082f28a..073065524b0d 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -2,6 +2,48 @@ import React, { type SVGProps, useId } from "react"; import { cn } from "~/lib/utils"; export type Icon = React.FC>; +// Official mark: https://neovim.io/logos/neovim-mark.svg +export const NeovimIcon: Icon = (props) => { + const id = useId(); + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; export const UltrafastIcon: Icon = (props) => ( diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 0d1ea8563447..efad79dc828c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.44", + "version": "0.0.65", "private": true, "files": [ "dist" diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b969ffb1c531..d01d8da7d174 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -136,24 +136,58 @@ export function collectExternalUsageLinks(presentations: LimitPresentations) { return [...links.values()]; } -/** Prefer the reported email; use an identical credential when no email is available. */ -function accountKey( - driver: ServerProvider["driver"], - email: string | undefined, - limits?: ServerProviderUsageLimits, -): string | null { - const normalizedEmail = email?.trim().toLowerCase(); - if (normalizedEmail) return `${driver}:${normalizedEmail}`; - return limits?.credentialFingerprint - ? `${driver}:credential:${limits.credentialFingerprint}` - : null; +function makeAccountKey( + configs: Iterable<{ + readonly providers?: readonly ServerProvider[] | undefined; + readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; + } | null>, +) { + const idsByEmail = new Map>(); + const emailKey = (driver: ServerProvider["driver"], email: string | undefined) => { + const normalized = email?.trim().toLowerCase(); + return normalized ? `${driver}:${normalized}` : null; + }; + for (const config of configs) { + const identities = [ + ...(config?.providers ?? []).map((provider) => ({ + driver: provider.driver, + ...provider.auth, + })), + ...(config?.usageLimitSources ?? []).flatMap((source) => source.accounts), + ]; + for (const identity of identities) { + const key = emailKey(identity.driver, identity.email); + const id = identity.accountId?.trim(); + if (!key || !id) continue; + const ids = idsByEmail.get(key) ?? new Set(); + ids.add(id); + idsByEmail.set(key, ids); + } + } + return ( + driver: ServerProvider["driver"], + email: string | undefined, + accountId: string | undefined, + limits: ServerProviderUsageLimits | undefined, + ): string | null => { + const key = emailKey(driver, email); + const ids = key ? idsByEmail.get(key) : undefined; + // Legacy reports can join a known workspace only when the email is unambiguous. + const id = accountId?.trim() || (ids?.size === 1 ? ids.values().next().value : undefined); + if (id) return `${driver}:account:${id}:${email?.trim().toLowerCase() ?? ""}`; + if (key) return key; + return limits?.credentialFingerprint + ? `${driver}:credential:${limits.credentialFingerprint}` + : null; + }; } /** * One subscription account as the pooled views see it, whichever way it was - * reported. Matching emails or credentials across environments name - * one account. Its quota is one bucket, so counting it twice would misstate - * what is left. + * reported. Workspace ID and email identify a Codex member's quota bucket. + * Email alone is a fallback for providers and older servers without an ID, + * and an identical credential stands in when no email is reported. Its quota + * is one bucket, so counting it twice would misstate what is left. */ export interface LimitAccount { readonly key: string; @@ -184,6 +218,7 @@ export interface LimitAccount { * native instances supply names and environment labels. */ export function collectLimitAccounts(presentations: LimitPresentations): readonly LimitAccount[] { + const accountKey = makeAccountKey(Array.from(presentations.values(), (p) => p.serverConfig)); const accounts = new Map(); const creditSources = new Map(); const hubRedeems = new Map(); @@ -251,8 +286,12 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; merge( - accountKey(provider.driver, provider.auth.email, provider.usageLimits) ?? - `${environmentId}:${provider.instanceId}`, + accountKey( + provider.driver, + provider.auth.email, + provider.auth.accountId, + provider.usageLimits, + ) ?? `${environmentId}:${provider.instanceId}`, { key: `${environmentId}:${provider.instanceId}`, driver: provider.driver, @@ -280,7 +319,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; merge( - accountKey(account.driver, account.email, account.usageLimits) ?? + accountKey(account.driver, account.email, account.accountId, account.usageLimits) ?? `${source.id}:${account.id}`, { key: `${source.id}:${account.id}`, @@ -652,6 +691,7 @@ export function collectProviderUsageLimits( sources: UsageLimitSourceSnapshots, now: number, ): UsageLimitsReport | null { + const accountKey = makeAccountKey([{ providers, usageLimitSources: sources }]); const selected = providers.find((provider) => provider.instanceId === instanceId); if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; const native = providersWithLimits(providers).filter( @@ -659,7 +699,12 @@ export function collectProviderUsageLimits( ); const nativeAccounts = new Set( native.flatMap((provider) => { - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = accountKey( + provider.driver, + provider.auth.email, + provider.auth.accountId, + provider.usageLimits, + ); return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable ? [key] : []; @@ -669,13 +714,19 @@ export function collectProviderUsageLimits( const notices: string[] = []; for (const provider of native) { if (!provider.usageLimits) continue; - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = accountKey( + provider.driver, + provider.auth.email, + provider.auth.accountId, + provider.usageLimits, + ); const hubCredits = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .filter( ({ account }) => key !== null && - accountKey(account.driver, account.email, account.usageLimits) === key && + accountKey(account.driver, account.email, account.accountId, account.usageLimits) === + key && account.usageLimits.resetCredits && !limitsNotice(account.usageLimits), ) @@ -722,7 +773,7 @@ export function collectProviderUsageLimits( for (const source of sources) { const matching = source.accounts.filter((account) => account.driver === selected.driver); for (const account of matching) { - const key = accountKey(account.driver, account.email, account.usageLimits); + const key = accountKey(account.driver, account.email, account.accountId, account.usageLimits); if (key && nativeAccounts.has(key)) continue; accounts.push({ id: `${source.id}:${account.id}`,