From 5b7d72aad14ed37e8e5e4c02a6d49814bfe528ac Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 04:28:16 -0400 Subject: [PATCH 01/47] feat(updates): continue active threads across server restarts (#9167) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../settings/DesktopClientSettings.test.ts | 1 + apps/server/src/cloud/selfUpdate.test.ts | 217 ++++++++++ apps/server/src/cloud/selfUpdate.ts | 132 ++++++- .../src/desktopUpdate/DesktopAppUpdate.ts | 32 +- .../src/environment/ServerEnvironment.test.ts | 2 + .../src/environment/ServerEnvironment.ts | 5 +- .../src/provider/Layers/CodexAdapter.ts | 1 + .../provider/Layers/ProviderService.test.ts | 51 +++ .../src/provider/Layers/ProviderService.ts | 24 +- .../src/provider/Services/ProviderAdapter.ts | 3 + apps/server/src/server.test.ts | 2 + .../serverRuntimeStartup.reconcile.test.ts | 369 +++++++++++++++++- apps/server/src/serverRuntimeStartup.ts | 344 ++++++++++++++-- apps/server/src/ws.ts | 37 +- apps/web/src/components/ChatView.tsx | 4 + .../components/ServerUpdateAction.test.tsx | 50 +++ .../web/src/components/ServerUpdateAction.tsx | 14 +- .../settings/ConnectionsSettings.tsx | 5 + .../components/settings/SettingsPanels.tsx | 34 ++ .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/versionSkew.ts | 8 + docs/user/updating.md | 8 +- packages/contracts/src/environment.ts | 3 + packages/contracts/src/provider.ts | 3 + packages/contracts/src/server.ts | 4 + packages/contracts/src/settings.ts | 4 + 26 files changed, 1292 insertions(+), 71 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ef9cf7beef9b..95c5cc022c6d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -23,6 +23,7 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, confirmThreadUnpin: false, + continueThreadsAfterServerUpdate: true, contextWindowMeterEnabled: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index d7c383140e4d..e6d8010f19d7 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts"; import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -104,6 +106,221 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( }); it.layer(NodeServices.layer)("server self update", (it) => { + it.effect("marks running threads at the boot-service handoff", () => + Effect.gen(function* () { + const events: string[] = []; + const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ + mode: "web", + selfUpdate: { + update: (_input, reportProgress = () => Effect.void) => + reportProgress("downloading").pipe( + Effect.andThen(reportProgress("installing")), + Effect.as({ + targetVersion: "1.1.0", + method: "boot-service" as const, + updateId: "update-id", + }), + ), + commitDesktopUpdate: () => Effect.never, + }, + prepare: Effect.sync(() => { + events.push("prepare"); + return [ThreadId.make("thread-running")]; + }), + clear: () => Effect.sync(() => void events.push("clear")), + }); + + yield* selfUpdate.update({ targetVersion: "1.1.0", continueRunningThreads: true }, (stage) => + Effect.sync(() => void events.push(stage)), + ); + + expect(events).toEqual(["downloading", "prepare", "installing"]); + }), + ); + + it.effect("marks desktop threads only when the prepared update commits", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-running-desktop"); + const events: string[] = []; + const commitError = new ServerSelfUpdateError({ reason: "install failed" }); + const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ + mode: "desktop", + selfUpdate: { + update: (_input, reportProgress = () => Effect.void) => + reportProgress("installing").pipe( + Effect.as({ + targetVersion: "1.2.0", + method: "desktop-app" as const, + desktopUpdateToken: "desktop-token", + }), + ), + commitDesktopUpdate: () => + Effect.sync(() => events.push("commit")).pipe(Effect.andThen(Effect.fail(commitError))), + }, + prepare: Effect.sync(() => { + events.push("prepare"); + return [threadId]; + }), + clear: (threadIds) => Effect.sync(() => void events.push(`clear:${threadIds.join(",")}`)), + }); + + yield* selfUpdate.update({ targetVersion: "1.2.0", continueRunningThreads: true }, (stage) => + Effect.sync(() => void events.push(stage)), + ); + expect(events).toEqual(["installing"]); + expect(yield* selfUpdate.commitDesktopUpdate("desktop-token").pipe(Effect.flip)).toBe( + commitError, + ); + expect(events).toEqual(["installing", "prepare", "commit", `clear:${threadId}`]); + expect(yield* selfUpdate.commitDesktopUpdate("desktop-token").pipe(Effect.flip)).toBe( + commitError, + ); + expect(events).toEqual([ + "installing", + "prepare", + "commit", + `clear:${threadId}`, + "prepare", + "commit", + `clear:${threadId}`, + ]); + }), + ); + + it.effect("reports a failed continuation-marker cleanup", () => + Effect.gen(function* () { + const updateError = new ServerSelfUpdateError({ reason: "update failed" }); + const clearError = new ServerSelfUpdateError({ reason: "marker cleanup failed" }); + const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ + mode: "web", + selfUpdate: { + update: (_input, reportProgress = () => Effect.void) => + reportProgress("installing").pipe(Effect.andThen(Effect.fail(updateError))), + commitDesktopUpdate: () => Effect.never, + }, + prepare: Effect.succeed([ThreadId.make("thread-cleanup-failure")]), + clear: () => Effect.fail(clearError), + }); + + expect( + yield* selfUpdate + .update({ targetVersion: "1.1.0", continueRunningThreads: true }) + .pipe(Effect.flip), + ).toBe(clearError); + }), + ); + + it.effect("keeps continuation markers after the boot-service handoff is accepted", () => + Effect.gen(function* () { + const events: string[] = []; + const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ + mode: "web", + selfUpdate: { + update: ( + _input, + reportProgress = () => Effect.void, + onHandoffAccepted = () => Effect.void, + ) => + reportProgress("installing").pipe( + Effect.andThen(onHandoffAccepted()), + Effect.andThen(Effect.interrupt), + ), + commitDesktopUpdate: () => Effect.never, + }, + prepare: Effect.sync(() => { + events.push("prepare"); + return [ThreadId.make("thread-accepted-boot-handoff")]; + }), + clear: () => Effect.sync(() => void events.push("clear")), + }); + + const exit = yield* selfUpdate + .update({ targetVersion: "1.1.0", continueRunningThreads: true }) + .pipe(Effect.exit); + + expect(exit._tag).toBe("Failure"); + expect(events).toEqual(["prepare"]); + }), + ); + + it.effect("keeps continuation markers after the desktop handoff is accepted", () => + Effect.gen(function* () { + const events: string[] = []; + const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ + mode: "desktop", + selfUpdate: { + update: () => + Effect.succeed({ + targetVersion: "1.2.0", + method: "desktop-app" as const, + desktopUpdateToken: "accepted-desktop-token", + }), + commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) => + onHandoffAccepted().pipe(Effect.andThen(Effect.interrupt)), + }, + prepare: Effect.sync(() => { + events.push("prepare"); + return [ThreadId.make("thread-accepted-desktop-handoff")]; + }), + clear: () => Effect.sync(() => void events.push("clear")), + }); + + yield* selfUpdate.update({ + targetVersion: "1.2.0", + continueRunningThreads: true, + }); + const exit = yield* selfUpdate + .commitDesktopUpdate("accepted-desktop-token") + .pipe(Effect.exit); + + expect(exit._tag).toBe("Failure"); + expect(events).toEqual(["prepare"]); + }), + ); + + it.effect("clears continuation markers for mixed failure and interrupt causes", () => + Effect.gen(function* () { + const events: string[] = []; + const commitError = new ServerSelfUpdateError({ reason: "install failed" }); + const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ + mode: "desktop", + selfUpdate: { + update: () => + Effect.succeed({ + targetVersion: "1.2.0", + method: "desktop-app" as const, + desktopUpdateToken: "failed-desktop-token", + }), + commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) => + onHandoffAccepted().pipe( + Effect.andThen( + Effect.failCause( + Cause.fromReasons([ + Cause.makeFailReason(commitError), + Cause.makeInterruptReason(), + ]), + ), + ), + ), + }, + prepare: Effect.sync(() => [ThreadId.make("thread-failed-desktop-install")]), + clear: () => Effect.sync(() => void events.push("clear")), + }); + + yield* selfUpdate.update({ + targetVersion: "1.2.0", + continueRunningThreads: true, + }); + const exit = yield* selfUpdate.commitDesktopUpdate("failed-desktop-token").pipe(Effect.exit); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(Cause.hasInterrupts(exit.cause)).toBe(true); + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(false); + } + expect(events).toEqual(["clear"]); + }), + ); + it.effect("stages and preflights before asking the launcher for an update ID", () => Effect.gen(function* () { const { selfUpdate, order } = yield* makeHarness(); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 1c4f9aa75109..3cea30790a4c 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -4,15 +4,18 @@ import { type ServerSelfUpdateInput, type ServerSelfUpdateProgressStage, type ServerSelfUpdateResult, + type ThreadId, } from "@t3tools/contracts"; import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as HashSet from "effect/HashSet"; +import * as Ref from "effect/Ref"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; import * as ServerConfig from "../config.ts"; import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; @@ -41,14 +44,125 @@ export class ServerSelfUpdate extends Context.Service< { readonly update: ( input: ServerSelfUpdateInput, - reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, + reportProgress?: ( + stage: ServerSelfUpdateProgressStage, + ) => Effect.Effect, + onHandoffAccepted?: () => Effect.Effect, ) => Effect.Effect; readonly commitDesktopUpdate: ( requestId: string, + onHandoffAccepted?: () => Effect.Effect, ) => Effect.Effect; } >()("t3/cloud/selfUpdate/ServerSelfUpdate") {} +export const withRunningThreadContinuation = Effect.fn( + "cloud.server_self_update.withRunningThreadContinuation", +)(function* (input: { + readonly mode: ServerConfig.RuntimeMode; + readonly selfUpdate: ServerSelfUpdate["Service"]; + readonly prepare: Effect.Effect, ServerSelfUpdateError>; + readonly clear: ( + threadIds: ReadonlyArray, + ) => Effect.Effect; +}) { + const desktopContinuationTokens = yield* Ref.make(HashSet.empty()); + const clearOnError = ( + effect: Effect.Effect, + threadIds: () => ReadonlyArray, + handoffAccepted: () => boolean, + ): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => + (handoffAccepted() && Cause.hasInterruptsOnly(cause) + ? Effect.void + : input.clear(threadIds()) + ).pipe(Effect.andThen(Effect.failCause(cause))), + ), + ); + + const update: ServerSelfUpdate["Service"]["update"] = ( + request, + reportProgress = () => Effect.void, + ) => { + let prepared = false; + let handoffAccepted = false; + let continuationThreadIds: ReadonlyArray = []; + return clearOnError( + input.selfUpdate + .update( + request, + (stage) => + (request.continueRunningThreads === true && + input.mode !== "desktop" && + stage === "installing" && + !prepared + ? input.prepare.pipe( + Effect.tap((threadIds) => + Effect.sync(() => { + prepared = true; + continuationThreadIds = threadIds; + }), + ), + Effect.asVoid, + ) + : Effect.void + ).pipe(Effect.andThen(reportProgress(stage))), + () => + Effect.sync(() => { + handoffAccepted = true; + }), + ) + .pipe( + Effect.tap((result) => { + if ( + result.method === "desktop-app" && + result.desktopUpdateToken !== undefined && + request.continueRunningThreads === true + ) { + return Ref.update(desktopContinuationTokens, HashSet.add(result.desktopUpdateToken)); + } + return Effect.void; + }), + ), + () => continuationThreadIds, + () => handoffAccepted, + ); + }; + + return ServerSelfUpdate.of({ + update, + commitDesktopUpdate: (requestId) => + Effect.gen(function* () { + const shouldContinue = yield* Ref.modify(desktopContinuationTokens, (tokens) => [ + HashSet.has(tokens, requestId), + HashSet.remove(tokens, requestId), + ]); + let handoffAccepted = false; + let continuationThreadIds: ReadonlyArray = []; + return yield* clearOnError( + Effect.gen(function* () { + continuationThreadIds = shouldContinue ? yield* input.prepare : []; + return yield* input.selfUpdate.commitDesktopUpdate(requestId, () => + Effect.sync(() => { + handoffAccepted = true; + }), + ); + }), + () => continuationThreadIds, + () => handoffAccepted, + ).pipe( + Effect.catchCause((cause) => + (shouldContinue && !handoffAccepted + ? Ref.update(desktopContinuationTokens, HashSet.add(requestId)) + : Effect.void + ).pipe(Effect.andThen(Effect.failCause(cause))), + ), + ); + }), + }); +}); + export const make = Effect.fn("cloud.server_self_update.make")(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate; @@ -68,7 +182,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( "cloud.server_self_update.update", - )(function* (input, reportProgress = () => Effect.void) { + )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) { if (capability === "desktop-managed") { // input.targetVersion is meaningless here: the desktop app's own // update feed decides what it downloads, and the result carries what @@ -180,9 +294,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { ); yield* reportProgress("installing"); - const updateId = yield* launcher - .requestUpdate({ targetVersion, dbPath: serverConfig.dbPath }) - .pipe( + const updateId = yield* Effect.uninterruptible( + launcher.requestUpdate({ targetVersion, dbPath: serverConfig.dbPath }).pipe( Effect.mapError((error) => failWith( error._tag === "ServiceLauncherRejectedError" @@ -191,7 +304,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { error, ), ), - ); + Effect.tap(() => onHandoffAccepted()), + ), + ); yield* Effect.logInfo("Server update prepared; handing off to the service launcher.", { updateId, @@ -204,7 +319,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { return ServerSelfUpdate.of({ update, - commitDesktopUpdate: (requestId) => desktopAppUpdate.commit(requestId), + commitDesktopUpdate: (requestId, onHandoffAccepted) => + desktopAppUpdate.commit(requestId, onHandoffAccepted), }); }); diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts index 9770aa083a2f..1e7b54b2474b 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -49,11 +49,16 @@ export class DesktopAppUpdate extends Context.Service< /** Checks and downloads through the desktop app, then returns a token while this server is still connected. `commit` starts installation. */ readonly run: ( - reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, + reportProgress: ( + stage: ServerSelfUpdateProgressStage, + ) => Effect.Effect, ) => Effect.Effect; /** Starts the prepared install. Success stops this server, so this effect returns only when installation fails or times out. */ - readonly commit: (requestId: string) => Effect.Effect; + readonly commit: ( + requestId: string, + onHandoffAccepted?: () => Effect.Effect, + ) => Effect.Effect; } >()("t3/desktopUpdate/DesktopAppUpdate") {} @@ -72,11 +77,15 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( const consumeReports = ( requestId: string, changes: Stream.Stream, - reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, + reportProgress: ( + stage: ServerSelfUpdateProgressStage, + ) => Effect.Effect, ) => Effect.gen(function* () { const lastStage = yield* Ref.make(null); - const emitStage = (stage: ServerSelfUpdateProgressStage | null): Effect.Effect => + const emitStage = ( + stage: ServerSelfUpdateProgressStage | null, + ): Effect.Effect => stage === null ? Effect.void : Ref.get(lastStage).pipe( @@ -90,7 +99,9 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( const terminal = yield* changes.pipe( Stream.filter((report) => report.requestId === requestId), Stream.mapEffect( - (report): Effect.Effect> => + ( + report, + ): Effect.Effect, ServerSelfUpdateError> => report.outcome === undefined ? emitStage(desktopUpdateProgressStage(report.state)).pipe( Effect.as(Option.none()), @@ -176,7 +187,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( const commit: DesktopAppUpdate["Service"]["commit"] = Effect.fn( "desktopUpdate.desktopAppUpdate.commit", - )(function* (requestId) { + )(function* (requestId, onHandoffAccepted = () => Effect.void) { if (!available) { return yield* failWith("This server cannot commit a desktop app update."); } @@ -187,11 +198,12 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( onNone: () => changes, onSome: (report) => Stream.concat(Stream.make(report), changes), }); - yield* receiver - .commitDesktopUpdate(requestId) - .pipe( + yield* Effect.uninterruptible( + receiver.commitDesktopUpdate(requestId).pipe( Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app.", error)), - ); + Effect.tap(() => onHandoffAccepted()), + ), + ); return yield* reports.pipe( Stream.filter((report) => report.requestId === requestId && report.outcome === "failed"), Stream.runHead, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 3b68c1024dfb..91895fd5dcfc 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -244,11 +244,13 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed"); expect(withFd.capabilities.desktopAppUpdate).toBe(true); expect(withFd.capabilities.serverSelfUpdateProgress).toBe(true); + expect(withFd.capabilities.serverUpdateThreadContinuation).toBe(true); const withoutFd = yield* describeWith({ mode: "desktop" }); expect(withoutFd.capabilities.serverSelfUpdate).toBe("desktop-managed"); expect(withoutFd.capabilities.desktopAppUpdate).toBeUndefined(); expect(withoutFd.capabilities.serverSelfUpdateProgress).toBeUndefined(); + expect(withoutFd.capabilities.serverUpdateThreadContinuation).toBeUndefined(); const web = yield* describeWith({ mode: "web", desktopTelemetryControlFd: 5 }); expect(web.capabilities.desktopAppUpdate).toBeUndefined(); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 366b2f5b41b0..f21a410a96c9 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -224,7 +224,10 @@ export const make = Effect.gen(function* () { threadPullRequestLinking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate - ? { serverSelfUpdateProgress: true } + ? { + serverSelfUpdateProgress: true, + serverUpdateThreadContinuation: true, + } : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), }, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 6eaccf9f47a0..1aed82a28868 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2010,6 +2010,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + promptlessTurnContinuation: true, }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 829c56177531..83f22d475b4a 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -246,6 +246,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { provider, capabilities: { sessionModelSwitch: "in-session", + ...(provider === CODEX_DRIVER ? { promptlessTurnContinuation: true } : {}), }, startSession, sendTurn, @@ -960,6 +961,56 @@ it.effect( ); routing.layer("ProviderServiceLive routing", (it) => { + it.effect("allows promptless continuation only for capable providers", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const codexThreadId = asThreadId("thread-promptless-continuation"); + yield* provider.startSession(codexThreadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId: codexThreadId, + runtimeMode: "full-access", + }); + + yield* provider.sendTurn({ threadId: codexThreadId, continuation: true }); + assert.deepEqual(routing.codex.sendTurn.mock.calls.at(-1)?.[0], { + threadId: codexThreadId, + continuation: true, + }); + + const claudeThreadId = asThreadId("thread-promptless-continuation-unsupported"); + yield* provider.startSession(claudeThreadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId: claudeThreadId, + runtimeMode: "full-access", + }); + const failure = yield* Effect.flip( + provider.sendTurn({ threadId: claudeThreadId, continuation: true }), + ); + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, "requires an explicit continuation prompt"); + assert.equal(routing.claude.sendTurn.mock.calls.length, 0); + + yield* provider.stopSession({ threadId: claudeThreadId }); + routing.claude.startSession.mockClear(); + const stoppedFailure = yield* Effect.flip( + provider.sendTurn({ threadId: claudeThreadId, continuation: true }), + ); + assert.instanceOf(stoppedFailure, ProviderValidationError); + assert.include(stoppedFailure.issue, "requires an explicit continuation prompt"); + assert.equal(routing.claude.startSession.mock.calls.length, 0); + + yield* provider.stopSession({ threadId: codexThreadId }); + routing.codex.startSession.mockClear(); + routing.codex.sendTurn.mockClear(); + routing.codex.stopSession.mockClear(); + routing.claude.startSession.mockClear(); + routing.claude.sendTurn.mockClear(); + routing.claude.stopSession.mockClear(); + }), + ); + it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 74e86cf5a360..a75f2977d4d6 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -723,7 +723,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); const attachments = parsed.attachments ?? []; - if (!parsed.input && attachments.length === 0) { + if (!parsed.input && attachments.length === 0 && parsed.continuation !== true) { return yield* toValidationError( "ProviderService.sendTurn", "Either input text or at least one attachment is required", @@ -777,11 +777,29 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( let metricProvider = "unknown"; let metricModel = input.modelSelection?.model; return yield* Effect.gen(function* () { - const routed = yield* resolveRoutableSession({ + let routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.sendTurn", - allowRecovery: true, + allowRecovery: false, }); + if ( + input.continuation === true && + !input.input && + attachments.length === 0 && + routed.adapter.capabilities.promptlessTurnContinuation !== true + ) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Provider '${routed.adapter.provider}' requires an explicit continuation prompt`, + ); + } + if (!routed.isActive) { + routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.sendTurn", + allowRecovery: true, + }); + } metricProvider = routed.adapter.provider; metricModel = input.modelSelection?.model; yield* Effect.annotateCurrentSpan({ diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 634745832b37..dcf8eff4a27d 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -32,6 +32,9 @@ export interface ProviderAdapterCapabilities { * Declares whether changing the model on an existing session is supported. */ readonly sessionModelSwitch: ProviderSessionModelSwitchMode; + /** Starts a resumed turn with no synthetic user prompt. Omitted means the + adapter needs an explicit continuation instruction. */ + readonly promptlessTurnContinuation?: boolean; } export interface ProviderThreadTurnSnapshot { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 37370b16a133..d3e94e4eea44 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -937,6 +937,8 @@ const buildAppUnderTest = (options?: { Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({ awaitCommandReady: Effect.void, markHttpListening: Effect.void, + markRunningProviderSessionsForContinuation: Effect.succeed([]), + clearProviderSessionContinuationMarkers: () => Effect.void, enqueueCommand: (effect) => effect, ...options?.layers?.serverRuntimeStartup, }), diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 4cb9cc3b8268..aa1b1a7f9788 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -3,10 +3,12 @@ import { type OrchestrationCommand, ProviderDriverKind, ProviderInstanceId, + type ProviderSendTurnInput, ThreadId, TurnId, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; @@ -27,10 +29,12 @@ const makeThread = ( status: "starting" | "running" | "ready" | "stopped" | "error", activeTurnId: TurnId | null = null, archivedAt: string | null = null, + deletedAt: string | null = null, ) => ({ id: ThreadId.make(id), archivedAt, - deletedAt: null, + deletedAt, + interactionMode: "default" as const, session: { threadId: ThreadId.make(id), status, @@ -67,6 +71,7 @@ const queryWithThreads = (threads: ReadonlyArray>) const runReconciliation = (input: { readonly threads: ReadonlyArray>; readonly liveThreadIds?: ReadonlyArray; + readonly providerService?: ProviderService.ProviderService["Service"]; readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; readonly dispatch: OrchestrationEngine.OrchestrationEngineService["Service"]["dispatch"]; }) => @@ -77,7 +82,7 @@ const runReconciliation = (input: { ), Effect.provideService( ProviderService.ProviderService, - makeProviderService(input.liveThreadIds), + input.providerService ?? makeProviderService(input.liveThreadIds), ), Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { @@ -90,6 +95,347 @@ const runReconciliation = (input: { Effect.provide(NodeServices.layer), ); +it.effect("marks active running sessions that have persisted resume state", () => { + const active = makeThread("thread-mark-active", "running", TurnId.make("turn-mark-active")); + const archived = makeThread( + "thread-mark-archived", + "running", + TurnId.make("turn-mark-archived"), + updatedAt, + ); + const ready = makeThread("thread-mark-ready", "ready"); + const missingResumeState = makeThread( + "thread-mark-missing-resume-state", + "running", + TurnId.make("turn-mark-missing-resume-state"), + ); + const bindingReads: ThreadId[] = []; + const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = []; + + return ServerRuntimeStartup.markRunningProviderSessionsForContinuation.pipe( + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + queryWithThreads([active, archived, ready, missingResumeState]), + ), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { + getBinding: (threadId) => + Effect.sync(() => bindingReads.push(threadId)).pipe( + Effect.as( + Option.some({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + ...(threadId === active.id ? { resumeCursor: { threadId } } : {}), + runtimePayload: { activeTurnId: "turn-mark-active" }, + }), + ), + ), + upsert: (binding) => Effect.sync(() => upserts.push(binding)), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }), + Effect.tap((marked) => + Effect.sync(() => { + assert.deepStrictEqual(bindingReads, [active.id, missingResumeState.id]); + assert.deepStrictEqual(marked, [active.id]); + assert.deepStrictEqual(upserts[0]?.runtimePayload, { + activeTurnId: "turn-mark-active", + continueAfterServerUpdate: active.session.activeTurnId, + }); + }), + ), + ); +}); + +it.effect("continues marked sessions after activation with provider-specific input", () => + Effect.gen(function* () { + const codex = makeThread( + "thread-continue-codex", + "running", + TurnId.make("turn-continue-codex"), + ); + const fallback = makeThread("thread-continue-fallback", "starting"); + const fallbackContinuationTurnId = TurnId.make("turn-continue-fallback"); + const fallbackProviderInstanceId = ProviderInstanceId.make("claudeAgent"); + const continuationSent = yield* Deferred.make(); + const continuationCleared = yield* Deferred.make(); + const sends: ProviderSendTurnInput[] = []; + const dispatched: OrchestrationCommand[] = []; + const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = []; + const bindings = new Map( + [codex, fallback].map((thread) => [ + thread.id, + { + threadId: thread.id, + provider: + thread.id === codex.id + ? ProviderDriverKind.make("codex") + : ProviderDriverKind.make("claudeAgent"), + providerInstanceId: + thread.id === codex.id ? providerInstanceId : fallbackProviderInstanceId, + status: "running" as const, + runtimePayload: { + continueAfterServerUpdate: + thread.id === codex.id ? codex.session.activeTurnId : fallbackContinuationTurnId, + }, + }, + ]), + ); + const providerService: ProviderService.ProviderService["Service"] = { + ...makeProviderService(), + getCapabilities: (instanceId) => + Effect.succeed({ + sessionModelSwitch: "in-session", + ...(instanceId === providerInstanceId ? { promptlessTurnContinuation: true } : {}), + }), + sendTurn: (input) => + Effect.gen(function* () { + sends.push(input); + if (sends.length === 2) { + yield* Deferred.succeed(continuationSent, undefined); + } + return { + threadId: input.threadId, + turnId: TurnId.make(`continued-${String(input.threadId)}`), + }; + }), + }; + + yield* runReconciliation({ + threads: [codex, fallback], + providerService, + directory: { + getBinding: (threadId) => + Effect.sync(() => { + const binding = bindings.get(threadId); + return binding === undefined ? Option.none() : Option.some(binding); + }), + upsert: (binding) => + Effect.sync(() => { + bindings.set(binding.threadId, binding); + upserts.push(binding); + const clearedCount = upserts.filter((candidate) => { + const payload = candidate.runtimePayload; + return ( + payload !== null && + typeof payload === "object" && + !Array.isArray(payload) && + "continueAfterServerUpdate" in payload && + payload.continueAfterServerUpdate === null + ); + }).length; + return clearedCount === 1; + }).pipe( + Effect.flatMap((firstMarkerCleared) => + firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, + ), + ), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }, + dispatch: (command) => + Effect.sync(() => dispatched.push(command)).pipe( + Effect.as({ sequence: dispatched.length }), + ), + }); + yield* Deferred.await(continuationSent); + yield* Deferred.await(continuationCleared); + + assert.deepStrictEqual( + sends.toSorted((left, right) => String(left.threadId).localeCompare(String(right.threadId))), + [ + { threadId: codex.id, continuation: true, interactionMode: "default" }, + { + threadId: fallback.id, + input: "Continue where you left off.", + interactionMode: "default", + }, + ], + ); + assert.deepStrictEqual( + dispatched.map((command) => + command.type === "thread.session.set" + ? { + threadId: command.threadId, + status: command.session.status, + activeTurnId: command.session.activeTurnId, + } + : null, + ), + [ + { + threadId: codex.id, + status: "starting", + activeTurnId: null, + }, + { + threadId: fallback.id, + status: "starting", + activeTurnId: fallback.session.activeTurnId, + }, + ], + ); + for (const [thread, continuationTurnId] of [ + [codex, codex.session.activeTurnId], + [fallback, fallbackContinuationTurnId], + ] as const) { + assert.deepStrictEqual( + upserts + .filter((binding) => binding.threadId === thread.id) + .map((binding) => binding.runtimePayload)[0], + { + continueAfterServerUpdate: continuationTurnId, + activeTurnId: null, + }, + ); + } + assert.equal( + upserts.some((binding) => { + const payload = binding.runtimePayload; + return ( + payload !== null && + typeof payload === "object" && + !Array.isArray(payload) && + "continueAfterServerUpdate" in payload && + payload.continueAfterServerUpdate === null + ); + }), + true, + ); + }), +); + +it.effect("does not continue archived or deleted marked sessions", () => { + const archived = makeThread( + "thread-continue-archived", + "running", + TurnId.make("turn-continue-archived"), + updatedAt, + ); + const deleted = makeThread( + "thread-continue-deleted", + "running", + TurnId.make("turn-continue-deleted"), + null, + updatedAt, + ); + const sends: ProviderSendTurnInput[] = []; + const dispatched: OrchestrationCommand[] = []; + + return runReconciliation({ + threads: [archived, deleted], + providerService: { + ...makeProviderService(), + sendTurn: (input) => + Effect.sync(() => { + sends.push(input); + return { + threadId: input.threadId, + turnId: TurnId.make("unexpected-archived-turn"), + }; + }), + }, + directory: { + getBinding: (threadId) => { + const thread = threadId === archived.id ? archived : deleted; + return Effect.succeed( + Option.some({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running" as const, + resumeCursor: { cursor: threadId }, + runtimePayload: { + continueAfterServerUpdate: thread.session.activeTurnId, + }, + }), + ); + }, + upsert: () => Effect.void, + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }, + dispatch: (command) => + Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + assert.deepStrictEqual(sends, []); + assert.deepStrictEqual( + dispatched.map((command) => + command.type === "thread.session.set" + ? { threadId: command.threadId, status: command.session.status } + : null, + ), + [ + { threadId: archived.id, status: "error" }, + { threadId: deleted.id, status: "error" }, + ], + ); + }), + ), + ); +}); + +it.effect("retries continuation preparation before settling a persistent failure", () => { + const thread = makeThread( + "thread-continuation-preparation-failure", + "running", + TurnId.make("turn-continuation-preparation-failure"), + ); + const dispatched: OrchestrationCommand[] = []; + const failure = new OrchestrationCommandInvariantError({ + commandType: "thread.session.set", + detail: "simulated continuation preparation failure", + }); + + return runReconciliation({ + threads: [thread], + directory: { + getBinding: () => + Effect.succeed( + Option.some({ + threadId: thread.id, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running" as const, + resumeCursor: { cursor: thread.id }, + runtimePayload: { + continueAfterServerUpdate: thread.session.activeTurnId, + }, + }), + ), + upsert: () => Effect.void, + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }, + dispatch: (command) => { + if (command.type !== "thread.session.set") { + return Effect.die("unexpected command"); + } + dispatched.push(command); + return command.session.status === "starting" + ? Effect.fail(failure) + : Effect.succeed({ sequence: dispatched.length }); + }, + }).pipe( + Effect.tap(() => + Effect.sync(() => + assert.deepStrictEqual( + dispatched.map( + (command) => command.type === "thread.session.set" && command.session.status, + ), + ["starting", "starting", "error"], + ), + ), + ), + ); +}); + it.effect("reconciles multiple active and archived orphans but skips live sessions", () => { const starting = makeThread("thread-starting", "starting"); const running = makeThread("thread-running", "running", TurnId.make("turn-running")); @@ -123,7 +469,13 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio providerInstanceId, status: "running" as const, resumeCursor: { cursor: candidate }, - runtimePayload: { activeTurnId: "stale", unrelated: candidate }, + runtimePayload: { + activeTurnId: "stale", + unrelated: candidate, + ...(candidate === staleActiveTurn.id + ? { continueAfterServerUpdate: "turn-from-an-earlier-update" } + : {}), + }, }), ), ), @@ -157,7 +509,16 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio assert.equal(upserts.length, orphanIds.length); for (const binding of upserts) { assert.equal(binding.status, "stopped"); - assert.deepStrictEqual(binding.runtimePayload, { activeTurnId: null }); + assert.deepStrictEqual( + binding.runtimePayload, + binding.threadId === staleActiveTurn.id + ? { + activeTurnId: null, + unrelated: binding.threadId, + continueAfterServerUpdate: null, + } + : { activeTurnId: null, unrelated: binding.threadId }, + ); assert.deepStrictEqual(binding.resumeCursor, { cursor: binding.threadId }); } }), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 3f1c212af91d..f2e541fdb521 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -6,6 +6,7 @@ import { ProjectId, ProviderInstanceId, ThreadId, + TurnId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; @@ -65,6 +66,13 @@ export class ServerRuntimeStartup extends Context.Service< { readonly awaitCommandReady: Effect.Effect; readonly markHttpListening: Effect.Effect; + readonly markRunningProviderSessionsForContinuation: Effect.Effect< + ReadonlyArray, + ServerUpdateThreadContinuationError + >; + readonly clearProviderSessionContinuationMarkers: ( + threadIds: ReadonlyArray, + ) => Effect.Effect; readonly enqueueCommand: ( effect: Effect.Effect, ) => Effect.Effect; @@ -294,6 +302,138 @@ const runStartupPhase = (phase: string, effect: Effect.Effect) const ORPHANED_PROVIDER_SESSION_ERROR = "Provider session did not survive a server restart. Send a new message to continue."; +const SERVER_UPDATE_CONTINUATION_KEY = "continueAfterServerUpdate"; +const SERVER_UPDATE_CONTINUATION_PROMPT = "Continue where you left off."; + +class ProviderSessionContinuationError extends Schema.TaggedErrorClass()( + "ProviderSessionContinuationError", + { + threadId: ThreadId, + }, +) { + override get message(): string { + return `Could not continue thread '${this.threadId}': the provider instance is missing.`; + } +} + +export class ServerUpdateThreadContinuationError extends Schema.TaggedErrorClass()( + "ServerUpdateThreadContinuationError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not prepare running threads to continue after the update."; + } +} + +function hasServerUpdateContinuationMarker( + runtimePayload: unknown, +): runtimePayload is Record { + return ( + runtimePayload !== null && + typeof runtimePayload === "object" && + !Array.isArray(runtimePayload) && + SERVER_UPDATE_CONTINUATION_KEY in runtimePayload + ); +} + +function readRuntimePayload(runtimePayload: unknown): Record { + return runtimePayload !== null && + typeof runtimePayload === "object" && + !Array.isArray(runtimePayload) + ? (runtimePayload as Record) + : {}; +} + +const isServerUpdateThreadContinuationError = Schema.is(ServerUpdateThreadContinuationError); + +function readServerUpdateContinuationTurnId(runtimePayload: unknown): TurnId | null { + if (!hasServerUpdateContinuationMarker(runtimePayload)) { + return null; + } + const value = runtimePayload[SERVER_UPDATE_CONTINUATION_KEY]; + return typeof value === "string" && value.length > 0 ? TurnId.make(value) : null; +} + +const toServerUpdateThreadContinuationError = (cause: unknown) => + isServerUpdateThreadContinuationError(cause) + ? cause + : new ServerUpdateThreadContinuationError({ cause }); + +export const markRunningProviderSessionsForContinuation = Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const { threads } = yield* query.getCommandReadModel(); + const running = threads.filter( + (thread) => + thread.archivedAt === null && + thread.deletedAt === null && + thread.session?.status === "running" && + thread.session.activeTurnId !== null, + ); + + const marked: ThreadId[] = []; + return yield* Effect.gen(function* () { + for (const thread of running) { + const activeTurnId = thread.session?.activeTurnId; + if (activeTurnId === null || activeTurnId === undefined) { + continue; + } + const binding = yield* directory.getBinding(thread.id); + if (Option.isNone(binding)) { + continue; + } + if (binding.value.resumeCursor === null || binding.value.resumeCursor === undefined) { + continue; + } + yield* directory.upsert({ + ...binding.value, + runtimePayload: { + ...readRuntimePayload(binding.value.runtimePayload), + [SERVER_UPDATE_CONTINUATION_KEY]: activeTurnId, + }, + }); + marked.push(thread.id); + } + return marked; + }).pipe( + Effect.catchCause((cause) => + clearProviderSessionContinuationMarkers(marked).pipe(Effect.andThen(Effect.failCause(cause))), + ), + ); +}).pipe(Effect.mapError(toServerUpdateThreadContinuationError)); + +const clearContinuationMarkers = ( + directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"], + threadIds: ReadonlyArray, +) => + Effect.forEach( + threadIds, + (threadId) => + directory.getBinding(threadId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (binding) => + directory.upsert({ + ...binding, + runtimePayload: { + ...readRuntimePayload(binding.runtimePayload), + [SERVER_UPDATE_CONTINUATION_KEY]: null, + }, + }), + }), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + +export const clearProviderSessionContinuationMarkers = (threadIds: ReadonlyArray) => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + yield* clearContinuationMarkers(directory, threadIds); + }).pipe(Effect.mapError(toServerUpdateThreadContinuationError)); export const reconcileProviderSessions = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -320,52 +460,165 @@ export const reconcileProviderSessions = Effect.gen(function* () { if (session === null) { continue; } - yield* Effect.gen(function* () { - const binding = yield* directory.getBinding(thread.id); - if (Option.isSome(binding)) { - yield* directory.upsert({ - ...binding.value, - status: "stopped", - runtimePayload: { activeTurnId: null }, - }); - } - }).pipe( + const binding = yield* directory.getBinding(thread.id).pipe( Effect.catchCause((cause) => Cause.hasInterrupts(cause) ? Effect.failCause(cause) - : Effect.logWarning("failed to reconcile orphaned provider session directory binding", { + : Effect.logWarning("failed to read orphaned provider session directory binding", { threadId: thread.id, cause, - }), + }).pipe(Effect.as(Option.none())), ), ); + const continuationMarkerPresent = + Option.isSome(binding) && hasServerUpdateContinuationMarker(binding.value.runtimePayload); + const continuationTurnId = Option.isSome(binding) + ? readServerUpdateContinuationTurnId(binding.value.runtimePayload) + : null; + const continuationMarked = + continuationTurnId !== null && + (session.activeTurnId === null || continuationTurnId === session.activeTurnId); + const settleAsError = (lastError: string) => + Effect.gen(function* () { + yield* Effect.gen(function* () { + if (Option.isSome(binding)) { + yield* directory.upsert({ + ...binding.value, + status: "stopped", + runtimePayload: { + ...readRuntimePayload(binding.value.runtimePayload), + activeTurnId: null, + ...(continuationMarkerPresent ? { [SERVER_UPDATE_CONTINUATION_KEY]: null } : {}), + }, + }); + } + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning( + "failed to reconcile orphaned provider session directory binding", + { threadId: thread.id, cause }, + ), + ), + ); - yield* Effect.gen(function* () { - const reconciledAt = DateTime.formatIso(yield* DateTime.now); - yield* orchestrationEngine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make(yield* crypto.randomUUIDv4), - threadId: thread.id, - session: { - ...session, - status: "error", - activeTurnId: null, - lastError: ORPHANED_PROVIDER_SESSION_ERROR, - updatedAt: reconciledAt, - }, - createdAt: reconciledAt, + yield* Effect.gen(function* () { + const reconciledAt = DateTime.formatIso(yield* DateTime.now); + yield* orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId: thread.id, + session: { + ...session, + status: "error", + activeTurnId: null, + lastError, + updatedAt: reconciledAt, + }, + createdAt: reconciledAt, + }); + }).pipe( + Effect.retry({ times: 1 }), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("failed to settle orphaned provider session projection", { + threadId: thread.id, + cause, + }), + ), + ); }); - }).pipe( - Effect.retry({ times: 1 }), - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to settle orphaned provider session projection", { + + if ( + Option.isSome(binding) && + continuationMarked && + thread.archivedAt === null && + thread.deletedAt === null + ) { + const prepared = yield* Effect.gen(function* () { + yield* directory.upsert({ + ...binding.value, + status: "starting", + runtimePayload: { + ...readRuntimePayload(binding.value.runtimePayload), + activeTurnId: null, + }, + }); + const resumedAt = DateTime.formatIso(yield* DateTime.now); + yield* orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId: thread.id, + session: { + ...session, + status: "starting", + activeTurnId: null, + lastError: null, + updatedAt: resumedAt, + }, + createdAt: resumedAt, + }); + }).pipe(Effect.retry({ times: 1 }), Effect.exit); + if (Exit.isFailure(prepared)) { + if (Cause.hasInterrupts(prepared.cause)) { + return yield* Effect.failCause(prepared.cause); + } + yield* Effect.logWarning("failed to prepare provider session continuation", { + threadId: thread.id, + cause: prepared.cause, + }); + yield* settleAsError(ORPHANED_PROVIDER_SESSION_ERROR); + continue; + } + + yield* forkParked( + Effect.gen(function* () { + const continuation = Effect.gen(function* () { + const providerInstanceId = binding.value.providerInstanceId; + if (providerInstanceId === undefined) { + return yield* new ProviderSessionContinuationError({ + threadId: thread.id, + }); + } + const capabilities = yield* providerService.getCapabilities(providerInstanceId); + yield* providerService.sendTurn({ threadId: thread.id, - cause, - }), - ), - ); + ...(capabilities.promptlessTurnContinuation === true + ? { continuation: true } + : { input: SERVER_UPDATE_CONTINUATION_PROMPT }), + interactionMode: thread.interactionMode, + }); + }); + const continuationExit = yield* Effect.exit(continuation); + if (Exit.isSuccess(continuationExit) || Cause.hasInterrupts(continuationExit.cause)) { + if (Exit.isSuccess(continuationExit)) { + yield* clearContinuationMarkers(directory, [thread.id]).pipe( + Effect.uninterruptible, + Effect.catchCause((cause) => + Effect.logWarning("failed to clear completed provider session continuation", { + threadId: thread.id, + cause, + }), + ), + ); + } + return; + } + yield* Effect.logWarning("failed to continue provider session after server update", { + threadId: thread.id, + cause: continuationExit.cause, + }); + yield* settleAsError( + "Could not continue this thread after the server update. Send a new message to continue.", + ).pipe(Effect.ignoreCause); + }), + ); + continue; + } + + yield* settleAsError(ORPHANED_PROVIDER_SESSION_ERROR); } }).pipe( Effect.catchCause((cause) => @@ -390,6 +643,8 @@ export const make = (options?: StartupOptions) => const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const crypto = yield* Crypto.Crypto; const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; @@ -577,6 +832,23 @@ export const make = (options?: StartupOptions) => return { awaitCommandReady: commandGate.awaitCommandReady, markHttpListening: Deferred.succeed(httpListening, undefined), + markRunningProviderSessionsForContinuation: markRunningProviderSessionsForContinuation.pipe( + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + Effect.provideService( + ProviderSessionDirectory.ProviderSessionDirectory, + providerSessionDirectory, + ), + ), + clearProviderSessionContinuationMarkers: (threadIds) => + clearProviderSessionContinuationMarkers(threadIds).pipe( + Effect.provideService( + ProviderSessionDirectory.ProviderSessionDirectory, + providerSessionDirectory, + ), + ), enqueueCommand: commandGate.enqueueCommand, } satisfies ServerRuntimeStartup["Service"]; }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0c4d2c5c89d8..28ade015f8b6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -50,7 +50,7 @@ import { ProviderUploadFeedbackError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, - type ServerSelfUpdateError, + ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, type FilesystemBrowseFailure, FilesystemBrowseError, @@ -509,7 +509,7 @@ const makeWsRpcLayer = ( const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; - const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; + const serverUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -1700,14 +1700,14 @@ const makeWsRpcLayer = ( }, ), [WS_METHODS.serverUpdateServer]: (input) => - observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { + observeRpcEffect(WS_METHODS.serverUpdateServer, serverUpdate.update(input), { "rpc.aggregate": "server", }), [WS_METHODS.serverUpdateServerWithProgress]: (input) => observeRpcStream( WS_METHODS.serverUpdateServerWithProgress, Stream.callback((queue) => - serverSelfUpdate + serverUpdate .update(input, (stage) => Queue.offer(queue, { type: "progress", @@ -1733,7 +1733,7 @@ const makeWsRpcLayer = ( [WS_METHODS.serverCommitDesktopUpdate]: (input) => observeRpcEffect( WS_METHODS.serverCommitDesktopUpdate, - serverSelfUpdate.commitDesktopUpdate(input.requestId), + serverUpdate.commitDesktopUpdate(input.requestId), { "rpc.aggregate": "server" }, ), [WS_METHODS.serverUpsertKeybinding]: (rule) => @@ -2578,7 +2578,32 @@ const makeWsRpcLayer = ( export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; + const baseServerSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; + const config = yield* ServerConfig.ServerConfig; + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; + const serverSelfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ + mode: config.mode, + selfUpdate: baseServerSelfUpdate, + prepare: startup.markRunningProviderSessionsForContinuation.pipe( + Effect.mapError( + (cause) => + new ServerSelfUpdateError({ + reason: "Could not prepare running threads to continue after the update.", + cause, + }), + ), + ), + clear: (threadIds) => + startup.clearProviderSessionContinuationMarkers(threadIds).pipe( + Effect.mapError( + (cause) => + new ServerSelfUpdateError({ + reason: "Could not clear thread continuation markers after the update failed.", + cause, + }), + ), + ), + }); const pullRequests = yield* PullRequestService.PullRequestService; return HttpRouter.add( "GET", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a0bc95a46c5b..868434446912 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -428,6 +428,7 @@ import { resolveServerSelfUpdateCapability, serverUpdateGuidance, supportsDesktopAppUpdate, + supportsServerUpdateThreadContinuation, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; @@ -2221,6 +2222,7 @@ function ChatViewContent(props: ChatViewProps) { const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); + const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); const serverUpdateState = useAtomValue( serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), ); @@ -2356,6 +2358,7 @@ function ChatViewContent(props: ChatViewProps) { serverLabel={versionMismatchServerLabel} selfUpdate={versionMismatchSelfUpdate} desktopAppUpdate={versionMismatchDesktopAppUpdate} + threadContinuation={versionMismatchThreadContinuation} targetVersion={versionMismatch.clientVersion} label={updateFailed ? "Retry" : "Update"} variant="ghost" @@ -2391,6 +2394,7 @@ function ChatViewContent(props: ChatViewProps) { serverUpdateEnvironmentId, versionMismatchSelfUpdate, versionMismatchDesktopAppUpdate, + versionMismatchThreadContinuation, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 0bc5ce7e84aa..584078c12bf4 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -8,11 +8,17 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ updateServer: vi.fn(), toast: vi.fn(), + continueThreadsAfterServerUpdate: false, })); vi.mock("~/hooks/useCopyToClipboard", () => ({ useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), })); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: ( + selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown, + ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }), +})); vi.mock("~/state/server", () => ({ serverEnvironment: { updateServer: Symbol("updateServer") }, })); @@ -47,6 +53,7 @@ describe("ServerUpdateAction", () => { beforeEach(() => { testState.updateServer.mockReset(); testState.toast.mockReset(); + testState.continueThreadsAfterServerUpdate = false; }); it("reports success only after the shared update flow reconnects", async () => { @@ -141,6 +148,49 @@ describe("ServerUpdateAction", () => { description: "Desktop app relaunched on 0.0.34.", }); }); + + it("leaves thread continuation off by default", async () => { + testState.updateServer.mockResolvedValue( + AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }), + ); + const action = ServerUpdateAction({ + environmentId: "env-test" as EnvironmentId, + serverLabel: "Test server", + selfUpdate: "boot-service", + threadContinuation: true, + targetVersion: "0.0.31", + }) as ActionElement; + + action.props.onClick?.(); + await flushPromises(); + + expect(testState.updateServer).toHaveBeenCalledWith({ + environmentId: "env-test", + input: { targetVersion: "0.0.31" }, + }); + }); + + it("applies the saved thread continuation preference automatically", async () => { + testState.updateServer.mockResolvedValue( + AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }), + ); + testState.continueThreadsAfterServerUpdate = true; + const action = ServerUpdateAction({ + environmentId: "env-test" as EnvironmentId, + serverLabel: "Test server", + selfUpdate: "boot-service", + threadContinuation: true, + targetVersion: "0.0.31", + }) as ActionElement; + + action.props.onClick?.(); + await flushPromises(); + + expect(testState.updateServer).toHaveBeenCalledWith({ + environmentId: "env-test", + input: { targetVersion: "0.0.31", continueRunningThreads: true }, + }); + }); }); describe("ServerUpdateProgress", () => { diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 5c1afaa3be65..4647eb541566 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -8,6 +8,7 @@ import type { ComponentProps } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useClientSettings } from "~/hooks/useSettings"; import { serverEnvironment } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; @@ -78,6 +79,7 @@ export function ServerUpdateAction({ serverLabel, selfUpdate, desktopAppUpdate = false, + threadContinuation = false, targetVersion, label = "Update", variant = "outline", @@ -88,11 +90,16 @@ export function ServerUpdateAction({ /** The desktop app supervising this server accepts remote update requests (capabilities.desktopAppUpdate). */ readonly desktopAppUpdate?: boolean; + /** The server can durably continue running provider turns after updating. */ + readonly threadContinuation?: boolean; readonly targetVersion: string; readonly label?: string; readonly variant?: ComponentProps["variant"]; }) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; + const continueThreadsAfterServerUpdate = useClientSettings( + (settings) => settings.continueThreadsAfterServerUpdate, + ); const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false, }); @@ -137,7 +144,12 @@ export function ServerUpdateAction({ try { const result = await updateServer({ environmentId, - input: { targetVersion }, + input: { + targetVersion, + ...(threadContinuation && continueThreadsAfterServerUpdate + ? { continueRunningThreads: true } + : {}), + }, }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 2c516fffe0c4..5aa244068cea 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -105,6 +105,7 @@ import { resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, supportsDesktopAppUpdate, + supportsServerUpdateThreadContinuation, } from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; @@ -1487,6 +1488,7 @@ function SavedBackendListRow({ serverLabel={`${environment.label} server`} selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} + threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)} targetVersion={versionMismatch.clientVersion} label={serverUpdateState.status === "failed" ? "Retry" : "Update"} /> @@ -3064,6 +3066,9 @@ export function ConnectionsSettings() { } selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)} desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)} + threadContinuation={supportsServerUpdateThreadContinuation( + primaryServerConfig, + )} targetVersion={primaryVersionMismatch.clientVersion} label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"} /> diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index efa6f87b27ca..8137114dcd93 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -533,6 +533,10 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks ? ["Provider update checks"] : []), + ...(settings.continueThreadsAfterServerUpdate !== + DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate + ? ["Continue threads after server updates"] + : []), ...(isBackgroundActivityDirty ? ["Background activity"] : []), ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] @@ -591,6 +595,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.glassOpacity, settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, + settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, @@ -681,6 +686,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, + continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, @@ -2185,6 +2191,34 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + continueThreadsAfterServerUpdate: + DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, + }) + } + /> + ) : null + } + control={ + + updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) }) + } + aria-label="Continue threads after server updates" + /> + } + /> + | null | undefined, +): boolean { + return serverConfig?.environment.capabilities.serverUpdateThreadContinuation === true; +} + /** The command to hand users whose server cannot update itself. */ export function manualServerUpdateCommand(targetVersion: string): string { return `npx t3@${targetVersion}`; diff --git a/docs/user/updating.md b/docs/user/updating.md index 022ce031e330..18fc82ad202d 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -15,8 +15,12 @@ update the server, and the version difference remains visible in Connections. ## Before You Update -Let active agent work and terminal commands finish first. Updating restarts the server, so the -connection will disappear briefly and work that is still running may be interrupted. +Updating restarts the server, so the connection will disappear briefly. **Settings** → **General** +has a **Continue threads after server updates** preference. It is off by default. When enabled, the +update buttons automatically resume supported provider threads after the replacement server is +ready. Providers with native promptless continuation use it; other providers receive a short +instruction to continue where they left off. Terminal commands and other running work may still be +interrupted during the update. The update does not remove saved threads, settings, or project files. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index e68892d903cf..533411e51375 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -93,6 +93,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server can stream self-update progress before acknowledging the restart. Clients fall back to server.updateServer when absent. */ serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), + /** Server can durably mark running provider turns before a self-update and + continue them after the replacement process starts. */ + serverUpdateThreadContinuation: Schema.optionalKey(Schema.Boolean), /** Agent-activity publishes (push notifications and Live Activities) currently leave this environment: the publish opt-in is enabled and the relay link credentials exist. Clients skip seeding a Live Activity when diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 42a943923037..3f9570a30233 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -67,6 +67,9 @@ export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; export const ProviderSendTurnInput = Schema.Struct({ threadId: ThreadId, + /** Internal recovery signal. Allows an empty turn only for adapters that + explicitly support promptless continuation. */ + continuation: Schema.optional(Schema.Boolean), input: Schema.optional( TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), ), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 0a5ac1f5269c..b05369145efe 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -754,6 +754,10 @@ export const ServerSelfUpdateInput = Schema.Struct({ /** Exact npm version of the `t3` package to install (never a dist-tag, so the server and the acknowledging client agree on what was requested). */ targetVersion: TrimmedNonEmptyString, + /** Opt-in recovery for provider turns that are running when the server + hands off to its replacement. Missing and false keep restart behavior + conservative under version skew. */ + continueRunningThreads: Schema.optionalKey(Schema.Boolean), }); export type ServerSelfUpdateInput = typeof ServerSelfUpdateInput.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 68a5c1e874cb..1b6e8949e32b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -207,6 +207,9 @@ export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + continueThreadsAfterServerUpdate: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -959,6 +962,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), + continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), From 7e9d5a7efa70f92f91d960a4f50243ba44d805da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 01:56:39 -0700 Subject: [PATCH 02/47] fix(mobile): prevent message and composer overlap (#9195) --- apps/mobile/src/features/threads/ThreadComposer.tsx | 11 ++++++++--- apps/mobile/src/features/threads/ThreadFeed.tsx | 4 ++++ apps/mobile/src/lib/wideMarkdownBlocks.test.ts | 8 ++++++++ apps/mobile/src/lib/wideMarkdownBlocks.ts | 13 +++++++++++-- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e4b34c2b022b..15c0ff1b4e18 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -151,6 +151,11 @@ export const COMPOSER_LAYOUT_TRANSITION = ? undefined : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System); +const COMPOSER_ATTACHMENT_ENTERING = + Platform.OS === "android" + ? FadeIn.duration(160) + : FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System); + const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface); export function ComposerSurface(props: { @@ -623,10 +628,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onPickFiles={props.onPickDraftFiles} /> ) : null} - {isExpanded ? ( + {isExpanded && props.draftAttachments.length > 0 ? ( 0 ? "px-[14px] pb-2.5" : undefined} - entering={FadeIn.duration(160)} + className="px-[14px] pb-2.5" + entering={COMPOSER_ATTACHMENT_ENTERING} exiting={FadeOut.duration(120)} > { ); }); + it("detects blockquotes only when the native renderer needs width pinning", () => { + expect(hasWideMarkdownBlock("> quoted", { includeBlockquotes: true })).toBe(true); + expect(hasWideMarkdownBlock(" > quoted", { includeBlockquotes: true })).toBe(true); + expect(hasWideMarkdownBlock("> quoted")).toBe(false); + expect(hasWideMarkdownBlock("prose > quoted", { includeBlockquotes: true })).toBe(false); + expect(hasWideMarkdownBlock(" > indented code", { includeBlockquotes: true })).toBe(false); + }); + it("detects GFM tables", () => { expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true); expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true); diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.ts b/apps/mobile/src/lib/wideMarkdownBlocks.ts index 3c7279fc4756..c4bd2864e472 100644 --- a/apps/mobile/src/lib/wideMarkdownBlocks.ts +++ b/apps/mobile/src/lib/wideMarkdownBlocks.ts @@ -1,6 +1,7 @@ /** - * Detects markdown that the JS renderer draws as a block requiring a definite - * user-bubble width — fenced code blocks, GFM tables, and ordered lists. + * Detects markdown that the renderer draws as a block requiring a definite + * user-bubble width: fenced code blocks, GFM tables, ordered lists, and + * blockquotes when requested by the caller. * * Fenced code blocks and tables report an intrinsic width equal to their * widest line, which is effectively unbounded. A user bubble sizes itself @@ -31,6 +32,7 @@ const BLOCKQUOTE_PREFIX = /^ {0,3}>[ \t]?/; export interface WideMarkdownBlockOptions { readonly includeOrderedLists?: boolean; + readonly includeBlockquotes?: boolean; } function stripBlockquotePrefixes(line: string): string { @@ -41,6 +43,10 @@ function stripBlockquotePrefixes(line: string): string { return content; } +function hasBlockquote(text: string): boolean { + return text.split("\n").some((line) => BLOCKQUOTE_PREFIX.test(line)); +} + function hasOrderedListItem(text: string): boolean { let previousNonEmptyLine: string | null = null; @@ -80,6 +86,9 @@ export function hasWideMarkdownBlock( if (FENCED_CODE_BLOCK.test(text)) { return true; } + if (options.includeBlockquotes === true && hasBlockquote(text)) { + return true; + } if (options.includeOrderedLists !== false && hasOrderedListItem(text)) { return true; } From f14f41b894448298a86865c9114e6700245356e7 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 05:03:47 -0400 Subject: [PATCH 03/47] fix(web): preserve composer draft during worktree setup (#9197) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/composerDraftStore.test.ts | 6 ++++-- apps/web/src/composerDraftStore.ts | 11 ++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index e1d9d462a661..068bb1ff41f0 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1458,17 +1458,18 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); }); - it("finalizes a promoted draft after the canonical thread route is active", () => { + it("moves composer edits made during promotion to the canonical thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); - store.setPrompt(draftId, "promote me"); markPromotedDraftThread(threadId); + store.setPrompt(draftId, "typed during setup"); finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); expect(draftByKey(draftId)).toBeUndefined(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("typed during setup"); }); it("finalizes a matching materialized draft even when promotion was not pre-marked", () => { @@ -1481,6 +1482,7 @@ describe("composerDraftStore project draft thread mapping", () => { expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); expect(draftByKey(draftId)).toBeUndefined(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("promote me"); }); it("updates branch context on an existing draft thread", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 6ef8c6518858..0032ababbae4 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1583,6 +1583,7 @@ function removeDraftThreadReferences( | "logicalProjectDraftThreadKeyByLogicalProjectKey" >, threadKey: string, + composerDestination?: ScopedThreadRef, ): Pick< ComposerDraftStoreState, | "draftThreadsByThreadKey" @@ -1597,7 +1598,11 @@ function removeDraftThreadReferences( const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } = state.draftThreadsByThreadKey; const { [threadKey]: removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; - revokeDraftThreadPreviewUrls(removedComposerDraft); + if (composerDestination && removedComposerDraft) { + restDraftsByThreadKey[composerTargetKey(composerDestination)] = removedComposerDraft; + } else { + revokeDraftThreadPreviewUrls(removedComposerDraft); + } return { draftsByThreadKey: restDraftsByThreadKey, draftThreadsByThreadKey: restDraftThreadsByThreadKey, @@ -2785,10 +2790,10 @@ const composerDraftStore = create()( } set((state) => { const existing = state.draftThreadsByThreadKey[threadKey]; - if (!isDraftThreadPromoting(existing)) { + if (!existing || !isDraftThreadPromoting(existing)) { return state; } - return removeDraftThreadReferences(state, threadKey); + return removeDraftThreadReferences(state, threadKey, existing.promotedTo ?? undefined); }); }, clearDraftThread: (threadRef) => { From 70cd258d8aac43ea57494527b00bf36de3efa6c0 Mon Sep 17 00:00:00 2001 From: G-R3 Date: Wed, 2 Sep 2026 05:15:02 -0400 Subject: [PATCH 04/47] fix(web): prevent two-digit list markers from being clipped (#9101) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/components/ChatMarkdown.test.tsx | 10 +++++----- apps/web/src/components/ChatMarkdown.tsx | 13 +++++-------- apps/web/src/index.css | 6 +++--- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 7e32e48d585c..c3e536d70ae2 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -360,13 +360,13 @@ describe("orderedListGutterStyle", () => { expect(orderedListGutterStyle(9, undefined)).toBeUndefined(); }); - it("leaves the default gutter alone for two-digit lists", () => { - expect(orderedListGutterStyle(99, undefined)).toBeUndefined(); + it("widens the gutter for two-digit lists", () => { + expect(orderedListGutterStyle(99, undefined)).toEqual({ "--list-gutter": "3ch" }); }); - it("leaves the default gutter alone for a two-digit list that starts above 1", () => { + it("widens the gutter for a two-digit list that starts above 1", () => { // start=50 + 49 items => last marker is "98", still two digits. - expect(orderedListGutterStyle(49, 50)).toBeUndefined(); + expect(orderedListGutterStyle(49, 50)).toEqual({ "--list-gutter": "3ch" }); }); it("widens the gutter once the last marker reaches three digits", () => { @@ -387,7 +387,7 @@ describe("orderedListGutterStyle", () => { it("uses the widest marker and includes a negative start's minus sign", () => { expect(orderedListGutterStyle(1001, -1000)).toEqual({ "--list-gutter": "6ch" }); expect(orderedListGutterStyle(3, -15)).toEqual({ "--list-gutter": "4ch" }); - expect(orderedListGutterStyle(3, -5)).toBeUndefined(); + expect(orderedListGutterStyle(3, -5)).toEqual({ "--list-gutter": "3ch" }); }); it("treats a missing/zero item count as a single item", () => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9bd66ac4880e..b08377e36a21 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -328,13 +328,10 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb } /** - * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits markers up to - * two characters wide. Once a marker reaches three characters (item 100+), - * `list-style-position: outside` paints it wider than that gutter and clips - * the leading character against the item's own overflow. Rather than widening - * the gutter for every list, only lists whose widest marker is 3+ characters - * get a wider `--list-gutter`. The width includes a negative marker's minus - * sign. + * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits one-character + * markers. Wider markers can extend past it and get clipped by a collapsed + * message's overflow. Widen the gutter to fit the widest marker, including a + * negative marker's minus sign. */ export function orderedListGutterStyle( itemCount: number, @@ -344,7 +341,7 @@ export function orderedListGutterStyle( const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); - if (markerWidth <= 2) return undefined; + if (markerWidth <= 1) return undefined; return { "--list-gutter": `${markerWidth + 1}ch` }; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index a5b400f0593c..94681211fe9c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1643,8 +1643,8 @@ code { } .chat-markdown ul { - /* Reset for nested uls under a widened ol — --list-gutter is an inherited - custom property, so without this a task-list under a 3+ digit ordered + /* Reset for nested uls under a widened ol. --list-gutter is an inherited + custom property, so without this a task-list under a multi-digit ordered list would inherit the outer gutter instead of its own default. */ --list-gutter: 1.25rem; padding-left: 1.25rem; @@ -1653,7 +1653,7 @@ code { /* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose - widest marker is 3+ characters, so item 100+ isn't clipped by list-style-position: + widest marker has multiple characters so it isn't clipped by list-style-position: outside painting the marker past the padding box. Reset it here too so a nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { From bc918e74ace5dbb4fe1ce73b59d06a9ca1be9ed3 Mon Sep 17 00:00:00 2001 From: Anirudh Coontoor Date: Wed, 2 Sep 2026 22:44:41 +0530 Subject: [PATCH 05/47] fix(server): discover project skills for Claude (#9210) Co-authored-by: Claude Fable 5.1 --- apps/server/src/provider/Drivers/ClaudeDriver.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f1606bea7d76..d47d9c062ae2 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -57,6 +57,7 @@ import { type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -212,6 +213,17 @@ export const ClaudeDriver: ProviderDriver = { }), ), ); + const snapshotForCwd = (cwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + discoverClaudeSkills(effectiveConfig, cwd, processEnv), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); return { instanceId, @@ -224,6 +236,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd, adapter, textGeneration, } satisfies ProviderInstance; From 6effe0a2fab1bcd48315485b580419377451c757 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:23:49 +0200 Subject: [PATCH 06/47] feat(web): redesign provider editor and models list (#8508) Co-authored-by: Claude Fable 5 --- .../settings/ProviderAccentColorPicker.tsx | 85 ++- .../settings/ProviderInstanceCard.tsx | 371 +++++----- .../settings/ProviderModelsSection.test.ts | 23 + .../settings/ProviderModelsSection.tsx | 649 +++++++++++------- .../settings/ProviderSettingsForm.tsx | 75 +- ...ProviderSettingsPanel.environment.test.tsx | 28 +- .../settings/ProviderSettingsPanel.tsx | 150 ++-- 7 files changed, 819 insertions(+), 562 deletions(-) create mode 100644 apps/web/src/components/settings/ProviderModelsSection.test.ts diff --git a/apps/web/src/components/settings/ProviderAccentColorPicker.tsx b/apps/web/src/components/settings/ProviderAccentColorPicker.tsx index d352257257a0..b6544d46db78 100644 --- a/apps/web/src/components/settings/ProviderAccentColorPicker.tsx +++ b/apps/web/src/components/settings/ProviderAccentColorPicker.tsx @@ -228,8 +228,17 @@ export function ProviderAccentColorPicker(props: { readonly onCommit: (value: string) => void; readonly description?: string; readonly commitDelayMs?: number; + /** `inline` renders only the swatch row, for callers that supply their own label. */ + readonly layout?: "stacked" | "inline"; }) { - const { commitDelayMs = 0, description, displayName, onCommit, value } = props; + const { + commitDelayMs = 0, + description, + displayName, + layout = "stacked", + onCommit, + value, + } = props; const [optimisticValue, setOptimisticValue] = useState(() => value ?? ""); const commitTimeoutRef = useRef | null>(null); const pendingCommitRef = useRef(null); @@ -295,40 +304,52 @@ export function ProviderAccentColorPicker(props: { : ""; const customSelected = Boolean(normalized && selectedValue === ""); + const swatchRow = ( +
+ + + +
+ ); + + if (layout === "inline") { + return swatchRow; + } + return (
Accent color -
- - - -
+ {swatchRow} {description ? {description} : null}
); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 75c0361e9c6a..14cac060e10c 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -5,6 +5,8 @@ import { CopyIcon, DownloadIcon, LoaderIcon, + LockIcon, + LockOpenIcon, PlusIcon, Trash2Icon, XIcon, @@ -28,12 +30,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -import { Checkbox } from "../ui/checkbox"; import { DraftInput } from "../ui/draft-input"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { ScrollArea } from "../ui/scroll-area"; import { Switch } from "../ui/switch"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { DriverOption } from "./providerDriverMeta"; @@ -53,6 +53,13 @@ import { const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +/** Label-left field grid for the Configuration tab: one row per field. */ +const PROVIDER_FIELD_GRID_CLASS_NAME = + "grid gap-x-4 gap-y-2.5 sm:grid-cols-[8rem_minmax(0,1fr)] sm:items-start"; +/** Full-width divider row that names the group of fields below it. */ +const PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME = + "col-span-full mt-1 border-t border-border/60 pt-2.5 text-[11px] text-muted-foreground"; + let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; @@ -235,118 +242,104 @@ function ProviderEnvironmentSection(props: { publishRows(nextRows); }; + const addVariable = () => + setRows([ + ...rows, + { + id: nextEnvironmentVariableDraftId(), + name: "", + value: "", + sensitive: true, + }, + ]); + return ( -
-
- Environment variables - + } + /> + + {variable.sensitive ? "Sensitive, stored separately" : "Plain text"} + + + +
+ ))} +
+ + + {rows.length === 0 + ? "API keys, base URLs, or other per-instance CLI settings." + : "Sensitive values are stored separately and never returned to the app."} +
- {rows.length === 0 ? ( -

- Add variables to pass API keys, base URLs, or other per-instance CLI settings. -

- ) : ( -
- - - - Variable - Value - Sensitive - - Options - - - - - {rows.map((variable, index) => ( - - - updateVariable(variable.id, { name: name.trim() })} - placeholder="VARIABLE_NAME" - spellCheck={false} - aria-label={`Environment variable name ${index + 1}`} - /> - - - updateVariable(variable.id, { value })} - type={variable.sensitive ? "password" : undefined} - autoComplete="off" - placeholder={ - variable.valueRedacted - ? "Stored secret - enter a new value to replace" - : "Value" - } - spellCheck={false} - aria-label={`Environment variable value ${index + 1}`} - /> - - -
- { - const sensitive = Boolean(checked); - updateVariable(variable.id, { - sensitive, - ...(sensitive && variable.valueRedacted === undefined - ? {} - : { valueRedacted: sensitive ? variable.valueRedacted : false }), - }); - }} - aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`} - /> -
-
- -
- -
-
-
- ))} -
-
-
- )} - - Sensitive values are stored separately and are not returned to the app after saving. -
); } @@ -362,7 +355,7 @@ interface ProviderInstanceCardProps { readonly readOnly?: boolean | undefined; readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; /** - * Pass `undefined` to hide the delete button entirely. Built-in default + * Pass `undefined` to hide the delete footer entirely. Built-in default * instance slots use `undefined` — they can't be deleted without losing * the slot, and their "reset to defaults" affordance lives on an outer * reset button instead. Explicit `| undefined` in the type accommodates @@ -489,6 +482,9 @@ export function ProviderInstanceCard({ liveModels: liveProvider?.models, customModels, }); + const hiddenModelCount = modelsForDisplay.filter( + (model) => !model.isCustom && hiddenModels.includes(model.slug), + ).length; const updateDisplayName = (value: string) => { const trimmed = value.trim(); @@ -581,35 +577,9 @@ export function ProviderInstanceCard({ ); - const titleTailNode = ( - <> - {headerAction ? ( - - {headerAction} - - ) : null} - {onDelete ? ( - - - - - - } - /> - Delete instance - - - ) : null} - - ); + const titleTailNode = headerAction ? ( + {headerAction} + ) : null; const versionCodeNode = versionLabel ? ( {versionLabel} @@ -634,7 +604,7 @@ export function ProviderInstanceCard({ className={cn( // Sidebar-style selection with a fixed row height so the list stays // even; the status line clamps to two lines instead of growing. - "group flex h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors", + "group flex min-h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors", // Foreground-alpha tint so the fill reads the same in light and dark themes. selected ? "bg-foreground/8" : "hover:bg-foreground/4", )} @@ -712,7 +682,7 @@ export function ProviderInstanceCard({ size="icon-xs" variant="ghost" className={cn( - "size-5 rounded-sm p-0", + "size-5 rounded-sm p-0 [--control-icon-color:currentColor]", versionAdvisory.emphasis === "strong" ? "text-warning hover:text-warning" : "text-muted-foreground hover:text-foreground", @@ -820,6 +790,25 @@ export function ProviderInstanceCard({

) : null} + {onDelete ? ( + + + + ) : null}
@@ -835,10 +824,14 @@ export function ProviderInstanceCard({ ) : null}
@@ -853,62 +846,60 @@ export function ProviderInstanceCard({
-
-
{driverOption !== undefined ? ( diff --git a/apps/web/src/components/settings/ProviderModelsSection.test.ts b/apps/web/src/components/settings/ProviderModelsSection.test.ts new file mode 100644 index 000000000000..83adbeb97320 --- /dev/null +++ b/apps/web/src/components/settings/ProviderModelsSection.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ServerProviderModel } from "@t3tools/contracts"; + +import { groupModelsForDisplay } from "./ProviderModelsSection"; + +function model(slug: string, isCustom = false): ServerProviderModel { + return { slug, name: slug, isCustom, capabilities: null }; +} + +describe("groupModelsForDisplay", () => { + it("lists favorites first, then visible models in user order, then hidden ones", () => { + const models = [model("a"), model("b"), model("c"), model("d"), model("custom", true)]; + + const display = groupModelsForDisplay(models, { + favoriteModels: new Set(["c"]), + hiddenModels: new Set(["a", "custom"]), + modelOrder: ["d", "b"], + }); + + // A custom model is never hidden, even if its slug is in the hidden set. + expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]); + }); +}); diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 007abea2b844..375d1ca419f9 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -1,16 +1,7 @@ "use client"; -import { - ArrowDownIcon, - ArrowUpIcon, - EyeIcon, - EyeOffIcon, - InfoIcon, - PlusIcon, - StarIcon, - XIcon, -} from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ProviderDriverKind, type ProviderInstanceId, @@ -23,7 +14,7 @@ import { sortModelsForProviderInstance } from "../../modelOrdering"; import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; /** @@ -38,6 +29,72 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial + descriptor.id === "fastMode" || + (descriptor.id === "serviceTier" && + descriptor.type === "select" && + descriptor.options.some((option) => option.id === "fast" || option.label === "Fast")), + ); + if (hasFastMode) labels.push("Fast mode"); + if (descriptors.some((descriptor) => descriptor.id === "thinking")) labels.push("Thinking"); + if ( + descriptors.some( + (descriptor) => + descriptor.type === "select" && + (descriptor.id === "reasoningEffort" || + descriptor.id === "effort" || + descriptor.id === "reasoning" || + descriptor.id === "variant"), + ) + ) { + labels.push("Reasoning"); + } + return labels; +} + +/** + * Display order for the models list: favorites first (in user order), then + * visible models, then hidden ones. Hidden models sink so the list reads + * top-down as "what the picker shows"; moves only swap rows within the same + * group, and the resulting display order is what gets persisted as + * `modelOrder`. + */ +export function groupModelsForDisplay< + T extends { readonly slug: string; readonly isCustom: boolean }, +>( + models: ReadonlyArray, + options: { + readonly favoriteModels: ReadonlySet; + readonly hiddenModels: ReadonlySet; + readonly modelOrder: ReadonlyArray; + }, +): T[] { + const ordered = sortModelsForProviderInstance(models, { + favoriteModels: options.favoriteModels, + groupFavorites: true, + modelOrder: options.modelOrder, + }); + const isHidden = (model: T) => !model.isCustom && options.hiddenModels.has(model.slug); + return [ + ...ordered.filter((model) => options.favoriteModels.has(model.slug)), + ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && !isHidden(model)), + ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && isHidden(model)), + ]; +} + interface ProviderModelsSectionProps { /** Identifier used to namespace input ids within the DOM. */ readonly instanceId: ProviderInstanceId; @@ -99,17 +156,50 @@ export function ProviderModelsSection({ onModelOrderChange, }: ProviderModelsSectionProps) { const [input, setInput] = useState(""); + const [isAdding, setIsAdding] = useState(false); + const [filter, setFilter] = useState(""); const [error, setError] = useState(null); - const listRef = useRef(null); + const listRef = useRef(null); + // Slug of a just-added custom model, scrolled into view once its row exists. + const scrollToSlugRef = useRef(null); const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]); const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]); - const orderedModels = useMemo(() => { - return sortModelsForProviderInstance(models, { - favoriteModels: favoriteModelSet, - groupFavorites: true, - modelOrder, - }); - }, [favoriteModelSet, modelOrder, models]); + const displayModels = useMemo( + () => + groupModelsForDisplay(models, { + favoriteModels: favoriteModelSet, + hiddenModels: hiddenModelSet, + modelOrder, + }), + [favoriteModelSet, hiddenModelSet, modelOrder, models], + ); + const favoriteCount = displayModels.filter((model) => favoriteModelSet.has(model.slug)).length; + const hiddenCount = displayModels.filter( + (model) => !model.isCustom && hiddenModelSet.has(model.slug), + ).length; + const showFilter = models.length > FILTER_THRESHOLD; + const normalizedFilter = filter.trim().toLowerCase(); + const isFiltering = showFilter && normalizedFilter.length > 0; + const visibleModels = isFiltering + ? displayModels.filter( + (model) => + model.name.toLowerCase().includes(normalizedFilter) || + model.slug.toLowerCase().includes(normalizedFilter), + ) + : displayModels; + + // The parent commits the new custom model and hands back an updated + // `models` list, so the row can only be scrolled to after that render. + useEffect(() => { + const slug = scrollToSlugRef.current; + if (slug === null) return; + const row = listRef.current?.querySelector( + `[data-model-slug="${CSS.escape(slug)}"]`, + ); + if (!row) return; + scrollToSlugRef.current = null; + row.scrollIntoView({ block: "nearest" }); + }, [displayModels]); const handleAdd = () => { const normalized = normalizeCustomModelSlug(input); @@ -130,24 +220,20 @@ export function ProviderModelsSection({ return; } + // Clear the filter so the new row renders even when it does not match, + // which is also what lets the pending scroll target resolve and clear. + scrollToSlugRef.current = normalized; + setFilter(""); onChange([...customModels, normalized]); setInput(""); setError(null); + setIsAdding(false); + }; - // Scroll the new row into view once the DOM reflects the commit. - // `MutationObserver` handles the one-frame gap between `onChange` and - // the `models` prop update; the `requestAnimationFrame` covers the - // common case where the parent updates synchronously. - const el = listRef.current; - if (!el) return; - const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - requestAnimationFrame(scrollToEnd); - const observer = new MutationObserver(() => { - scrollToEnd(); - observer.disconnect(); - }); - observer.observe(el, { childList: true, subtree: true }); - setTimeout(() => observer.disconnect(), 2_000); + const cancelAdd = () => { + setInput(""); + setError(null); + setIsAdding(false); }; const handleRemove = (slug: string) => { @@ -157,12 +243,11 @@ export function ProviderModelsSection({ setError(null); }; - const handleToggleHidden = (slug: string) => { - if (hiddenModelSet.has(slug)) { - onHiddenModelsChange(hiddenModels.filter((model) => model !== slug)); - return; - } - onHiddenModelsChange([...hiddenModels, slug]); + const setHidden = (slug: string, hidden: boolean) => { + if (hidden === hiddenModelSet.has(slug)) return; + onHiddenModelsChange( + hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug), + ); }; const handleToggleFavorite = (slug: string) => { @@ -173,237 +258,297 @@ export function ProviderModelsSection({ onFavoriteModelsChange([...favoriteModels, slug]); }; + // Rows only trade places with a neighbour in the same group (favorites, + // visible, hidden), and the display order is persisted as the new order. + const groupOf = (model: (typeof displayModels)[number]) => + favoriteModelSet.has(model.slug) + ? "favorite" + : !model.isCustom && hiddenModelSet.has(model.slug) + ? "hidden" + : "visible"; const handleMove = (slug: string, direction: -1 | 1) => { - const slugs = orderedModels.map((model) => model.slug); - const index = slugs.indexOf(slug); + const index = displayModels.findIndex((model) => model.slug === slug); const nextIndex = index + direction; - if (index < 0 || nextIndex < 0 || nextIndex >= slugs.length) { - return; - } - const next = [...slugs]; + if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return; + if (groupOf(displayModels[index]!) !== groupOf(displayModels[nextIndex]!)) return; + const next = displayModels.map((model) => model.slug); [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!]; onModelOrderChange(next); }; + type DisplayModel = (typeof displayModels)[number]; + + const starButton = (model: DisplayModel, isFavorite: boolean) => ( + + handleToggleFavorite(model.slug)} + aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ + isFavorite ? "from" : "to" + } favorites`} + /> + } + > + + + + {isFavorite ? "Remove from favorites" : "Add to favorites"} + + + ); + + // Reorder and remove stay in the row at all times (dimmed when unavailable) + // so ordering is discoverable without hovering. + const rowActions = ( + model: DisplayModel, + options: { + readonly isHidden: boolean; + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; + }, + ) => ( + + {!options.isHidden && !isFiltering ? ( + <> + + handleMove(model.slug, -1)} + aria-label={`Move ${model.name} up`} + /> + } + > + + + Move up + + + handleMove(model.slug, 1)} + aria-label={`Move ${model.name} down`} + /> + } + > + + + Move down + + + ) : null} + {model.isCustom ? ( + + handleRemove(model.slug)} + /> + } + > + + + Remove custom model + + ) : null} + + ); + + const pickerTooltip = (model: DisplayModel, isHidden: boolean) => + model.isCustom + ? "Custom models are always shown in the picker" + : isHidden + ? "Hidden from picker" + : "Shown in picker"; + + // The trigger is a wrapper span: a disabled switch gets no pointer events, + // so it could not open the tooltip itself. + const pickerSwitch = (model: DisplayModel, isHidden: boolean) => ( + + }> + setHidden(model.slug, !checked)} + aria-label={`Show ${model.name} in the model picker`} + /> + + {pickerTooltip(model, isHidden)} + + ); + + const renderRow = (model: DisplayModel) => { + const capLabels = describeModelCapabilities(model); + const group = groupOf(model); + // Hidden is read from the preference itself: a favorited model can still be + // hidden, and its switch must say so even though it sits in the favorites group. + const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); + const isFavorite = group === "favorite"; + const index = displayModels.indexOf(model); + const previousModel = displayModels[index - 1]; + const nextModel = displayModels[index + 1]; + // Reordering a filtered view would be ambiguous, so arrows only show on + // the full list. + const canMoveUp = + !isFiltering && previousModel !== undefined && groupOf(previousModel) === group; + const canMoveDown = !isFiltering && nextModel !== undefined && groupOf(nextModel) === group; + const nameClassName = cn("text-xs", isHidden ? "text-muted-foreground" : "text-foreground/90"); + + return ( +
+ {starButton(model, isFavorite)} + + {model.name} + {model.isCustom ? ( + custom + ) : model.name !== model.slug ? ( + + {model.slug} + + ) : null} + + {/* + Always a grid item so the columns line up across rows; the text + itself drops out on phone widths where it would starve the name. + */} + + {capLabels.length > 0 ? ( + {capLabels.join(" · ")} + ) : null} + + {rowActions(model, { isHidden, canMoveUp, canMoveDown })} + {pickerSwitch(model, isHidden)} +
+ ); + }; + + const groupLabel = (label: string, isFirst: boolean) => ( +
+ {label} +
+ ); + return (
-
Models
-
- {models.length} model{models.length === 1 ? "" : "s"} available. +
+ {showFilter ? ( + setFilter(event.target.value)} + placeholder="Filter models" + size="compact" + className="w-56" + spellCheck={false} + aria-label="Filter models" + /> + ) : null} + + {models.length} model{models.length === 1 ? "" : "s"} + {favoriteCount > 0 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` : ""} + {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""} +
- {orderedModels.map((model, index) => { - const caps = model.capabilities; - const capLabels: string[] = []; - const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); - const isFavorite = favoriteModelSet.has(model.slug); - const previousModel = orderedModels[index - 1]; - const nextModel = orderedModels[index + 1]; - const canMoveUp = - previousModel !== undefined && favoriteModelSet.has(previousModel.slug) === isFavorite; - const canMoveDown = - nextModel !== undefined && favoriteModelSet.has(nextModel.slug) === isFavorite; - const descriptors = caps?.optionDescriptors ?? []; - if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { - capLabels.push("Fast mode"); - } - if (descriptors.some((descriptor) => descriptor.id === "thinking")) { - capLabels.push("Thinking"); - } - if ( - descriptors.some( - (descriptor) => - descriptor.type === "select" && - (descriptor.id === "reasoningEffort" || - descriptor.id === "effort" || - descriptor.id === "reasoning" || - descriptor.id === "variant"), - ) - ) { - capLabels.push("Reasoning"); - } - const hasDetails = capLabels.length > 0 || model.name !== model.slug; - + {visibleModels.length === 0 ? ( +

+ {isFiltering ? "No models match." : "No models reported for this provider yet."} +

+ ) : null} + {visibleModels.map((model, index) => { + const group = groupOf(model); + const previous = visibleModels[index - 1]; + const startsGroup = previous === undefined || groupOf(previous) !== group; return ( -
-
- - {model.name} - - {hasDetails ? ( - - - } - > - - - -
- {model.slug} - {capLabels.length > 0 ? ( -
- {capLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
-
-
- ) : null} - {isHidden ? ( - hidden - ) : null} - {model.isCustom ? ( - custom - ) : null} -
-
- - handleToggleFavorite(model.slug)} - aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ - isFavorite ? "from" : "to" - } favorites`} - /> - } - > - - - - {isFavorite ? "Remove from favorites" : "Add to favorites"} - - - - handleMove(model.slug, -1)} - aria-label={`Move ${model.name} up`} - /> - } - > - - - Move up - - - handleMove(model.slug, 1)} - aria-label={`Move ${model.name} down`} - /> - } - > - - - Move down - - {!model.isCustom ? ( - - handleToggleHidden(model.slug)} - aria-label={`${isHidden ? "Show" : "Hide"} ${model.name}`} - /> - } - > - {isHidden ? ( - - ) : ( - - )} - - - {isHidden ? "Show in picker" : "Hide from picker"} - - - ) : null} - {model.isCustom ? ( - - handleRemove(model.slug)} - /> - } - > - - - Remove custom model - - ) : null} -
+
+ {startsGroup && favoriteCount > 0 && group === "favorite" + ? groupLabel("Favorites", index === 0) + : null} + {startsGroup && favoriteCount > 0 && group === "visible" + ? groupLabel("All", index === 0) + : null} + {startsGroup && group === "hidden" + ? groupLabel("Hidden from picker", index === 0) + : null} + {renderRow(model)}
); })}
-
- { - setInput(event.target.value); - if (error) setError(null); - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - handleAdd(); - }} - placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} - spellCheck={false} - /> - + +
+
+ ) : ( + -
+ )} {error ?

{error}

: null}
diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index cd34bb35c6b2..988ac9160412 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -158,7 +158,13 @@ interface ProviderSettingsFormProps { readonly definition: ProviderClientDefinition; readonly value: unknown; readonly idPrefix: string; - readonly variant: "card" | "dialog"; + /** + * `card` stacks label over control, `dialog` is the compact wizard layout, + * `grid` emits a label cell and a control cell per field for a parent + * two-column grid (label column left, control right), with the description + * beside a fixed-width control so each field stays on one line. + */ + readonly variant: "card" | "dialog" | "grid"; readonly onChange: (nextConfig: Record | undefined) => void; } @@ -189,14 +195,75 @@ function ProviderSettingsFieldRow({ }: ProviderSettingsFieldRowProps) { const inputId = `${idPrefix}-${field.key}`; const descriptionClassName = - variant === "card" - ? "mt-1 block text-xs text-muted-foreground" - : "text-[11px] text-muted-foreground"; + variant === "dialog" + ? "text-[11px] text-muted-foreground" + : "mt-1 block text-xs text-muted-foreground"; const label = {field.label}; const description = field.description ? ( {field.description} ) : null; + if (variant === "grid") { + // Label cell, then a control cell where the description sits beside a + // fixed-width control and wraps under it when the pane is narrow. The + // description is outside the label, so the control points at it instead. + const descriptionId = field.description ? `${inputId}-description` : undefined; + return ( + <> + {field.control === "switch" ? ( + {field.label} + ) : ( + + )} +
+ {field.control === "switch" ? ( + + + onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked))) + } + aria-label={field.label} + aria-describedby={descriptionId} + /> + + ) : field.control === "textarea" ? ( +