diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 25497bbf7221..e69a406e66bb 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -224,7 +224,10 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap enabled network access", { endpointUrl: serverExposureState.endpointUrl, }); - } else if (settings.serverExposureMode === "network-accessible") { + } else if ( + settings.serverExposureMode === "network-accessible" && + serverExposureState.mode === "local-only" + ) { yield* logBootstrapWarning( "bootstrap fell back to local-only because no advertised network host was available", ); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 4a8b516cb936..eb0becee0981 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -304,9 +304,9 @@ describe("DesktopServerExposure", () => { ); }); - it.effect("resolves advertised endpoints from the scoped runtime state", () => + it.effect("keeps LAN and Tailscale endpoints distinct when Tailscale is enumerated first", () => withHarness( - { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, + { ...tailnetNetworkInterfaces, ...lanNetworkInterfaces }, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); @@ -321,6 +321,32 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("keeps Tailscale-only hosts network-accessible", () => + withHarness( + tailnetNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "network-accessible"); + assert.equal(state.advertisedHost, null); + assert.equal(state.endpointUrl, null); + assert.equal((yield* serverExposure.backendConfig).bindHost, "0.0.0.0"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => [endpoint.reachability, endpoint.httpBaseUrl]), + [ + ["loopback", "http://127.0.0.1:4173/"], + ["private-network", "http://100.90.1.2:4173/"], + ], + ); + }), + ), + ); + it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => withHarness( lanNetworkInterfaces, @@ -342,7 +368,7 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("uses ConfigProvider desktop exposure overrides", () => + it.effect("preserves explicit Tailscale exposure overrides", () => withHarness( lanNetworkInterfaces, Effect.gen(function* () { @@ -350,17 +376,17 @@ describe("DesktopServerExposure", () => { yield* serverExposure.configureFromSettings({ port: 4173 }); const change = yield* serverExposure.setMode("network-accessible"); - assert.equal(change.state.advertisedHost, "10.0.0.7"); - assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); + assert.equal(change.state.advertisedHost, "100.90.1.2"); + assert.equal(change.state.endpointUrl, "http://100.90.1.2:4173"); const endpoints = yield* serverExposure.getAdvertisedEndpoints; assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], + ["http://127.0.0.1:4173/", "http://100.90.1.2:4173/", "https://public.example.test/"], ); }), { - T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", + T3CODE_DESKTOP_LAN_HOST: "100.90.1.2", T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", }, ), diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 6c3cd55527eb..24c24c15a00f 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,7 +9,7 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -65,7 +65,9 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined => }; const isUsableLanIpv4Address = (address: string): boolean => - !address.startsWith("127.") && !address.startsWith("169.254."); + !address.startsWith("127.") && + !address.startsWith("169.254.") && + !isTailscaleIpv4Address(address); const isHttpsEndpointUrl = (value: string): boolean => { try { @@ -376,7 +378,14 @@ function resolveRuntimeState(input: { ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); const unavailable = - input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null; + input.requestedMode === "network-accessible" && + requestedExposure.endpointUrl === null && + !Object.values(input.networkInterfaces).some((addresses) => + addresses?.some( + (address) => + !address.internal && address.family === "IPv4" && isTailscaleIpv4Address(address.address), + ), + ); const exposure = unavailable ? resolveDesktopServerExposure({ mode: "local-only", diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 6487e572c87d..6cc9ce8607d1 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,11 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId } from "@t3tools/contracts"; -import { - isRelayManagedConnection, - redactPairingCredential, - toStableSavedRemoteConnection, -} from "./connection"; +import { isRelayManagedConnection, toStableSavedRemoteConnection } from "./connection"; import { authClientMetadata } from "./authClientMetadata"; const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); @@ -83,23 +79,6 @@ describe("mobile remote connection records", () => { }); }); - it("removes one-time bootstrap credentials before persisting pairing URLs", () => { - expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe( - "https://desktop.example/", - ); - expect(redactPairingCredential("https://desktop.example/?token=bootstrap-token")).toBe( - "https://desktop.example/", - ); - }); - - it("removes hosted pairing credentials while keeping the advertised host", () => { - expect( - redactPairingCredential( - "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&token=bootstrap-token&label=Desktop", - ), - ).toBe("https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&label=Desktop"); - }); - it("recognizes explicitly managed relay connections", () => { expect(isRelayManagedConnection({ relayManaged: true })).toBe(true); }); diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index df26a192cd0f..5919a805ddd2 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -1,5 +1,4 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; export interface SavedRemoteConnection { @@ -17,15 +16,6 @@ export interface SavedRemoteConnection { export type RemoteClientConnectionState = EnvironmentConnectionPhase; -export function redactPairingCredential(pairingUrl: string): string { - const trimmed = pairingUrl.trim(); - try { - return stripPairingTokenFromUrl(new URL(trimmed)).toString(); - } catch { - return trimmed; - } -} - export function isRelayManagedConnection( connection: Pick, ): boolean { diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index f34f0f5cf4d7..a0ca9e5f0fa0 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -38,6 +39,84 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => }); it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { + it.effect("installs through pnpm when its Node runtime has no npm executable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" }); + const commands: Array = []; + const install = successfulRunner(fs, path); + const paths = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: ProcessRunner.ProcessRunner.of({ + run: (input) => { + commands.push(input); + return input.command === "npm" + ? Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "npm", + argumentCount: input.args.length, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ) + : install.run(input); + }, + }), + validate: (staging) => + fs.exists(staging.entryPath).pipe( + Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), + Effect.orDie, + ), + }); + assert.deepEqual( + commands.map((command) => command.command), + ["npm", "pnpm"], + ); + assert.deepEqual(commands[1]!.args, ["--package=npm@11", "dlx", "npm", ...commands[0]!.args]); + assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n"); + }), + ); + + it.effect("does not try a different installer for npm permission failures", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-permission-" }); + const commands: string[] = []; + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: ProcessRunner.ProcessRunner.of({ + run: (input) => { + commands.push(input.command); + return Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + }), + }), + ); + }, + }), + validate: () => Effect.die("must not validate a failed install"), + }).pipe(Effect.flip); + assert.deepEqual(commands, ["npm"]); + }), + ); + it.effect("validates a staging tree before atomically publishing it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 06628d5cc12f..cf8a7c3cab6b 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -2,6 +2,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; @@ -152,14 +153,35 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( return yield* Effect.gen(function* () { const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + const installArgs = [ + "install", + "--prefix", + stagingDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ]; yield* runner .run({ command: "npm", - args: ["install", "--prefix", stagingDir, "--no-fund", "--no-audit", `t3@${input.version}`], + args: installArgs, // Native dependencies may compile from source on slower machines. timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, }) .pipe( + Effect.catchTags({ + ProcessSpawnError: (error) => + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? // pnpm-managed Node installations do not include npm. Keep npm + // installation semantics for the pinned runtime and native builds. + runner.run({ + command: "pnpm", + args: ["--package=npm@11", "dlx", "npm", ...installArgs], + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + : Effect.fail(error), + }), Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), Effect.filterOrFail( (result) => result.code === 0, diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index f009255e90a2..88961641b248 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -61,7 +61,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( } = {}, ) { const authenticated = yield* Deferred.make(); - const discovered = yield* Deferred.make(); + const discovered = yield* Deferred.make(); const closed = yield* Deferred.make(); const events: string[] = []; let receiveAuthorizationUrl: @@ -224,6 +224,33 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); + it.effect( + "distinguishes a post-authentication session failure without exposing its payload", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.fail( + harness.discovered, + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Internal error ${callbackUrl}`, + method: "session/new", + }), + ); + const failed = yield* phase(harness.auth, "failed"); + assert.equal( + failed.message, + "Antigravity authenticated, but could not initialize a session or load models.", + ); + assert.isNull(failed.authorizationUrl); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + yield* Deferred.await(harness.closed); + }), + ); + it.effect("does not call callback HTTP success a successful Google sign-in", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index c7118bccad83..170e5c32f47a 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -114,6 +114,9 @@ function safeAuthFailure(cause: Cause.Cause, usesBrowser: boolean): str if (/access_denied|denied access|cancelled/i.test(error.value.errorMessage)) { return "Google sign-in was not approved. Start sign-in again."; } + if (error.value.method === "session/new" && error.value.code === -32603) { + return "Antigravity authenticated, but could not initialize a session or load models."; + } if (!usesBrowser && error.value.code === -32602) { return "Antigravity rejected the configured credentials. Check the provider settings."; } diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 85c80f95eaa6..68bd75dc6e53 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -30,6 +30,7 @@ import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus, + makeCursorModelDiscovery, enrichCursorSnapshot, } from "../Layers/CursorProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; @@ -140,7 +141,12 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); + const checkProvider = checkCursorProviderStatus( + effectiveConfig, + processEnv, + discoverModels, + ).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 4fa382c788d5..adda9f44d465 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -16,6 +16,7 @@ import { buildCursorCapabilitiesFromConfigOptions, checkCursorProviderStatus, discoverCursorModelsViaAcp, + makeCursorModelDiscovery, getCursorParameterizedModelPickerUnsupportedMessage, parseCursorAboutOutput, parseCursorCliConfigChannel, @@ -627,6 +628,42 @@ describe("checkCursorProviderStatus", () => { }); describe("discoverCursorModelsViaAcp", () => { + it("reuses successful discovery until the CLI version or account changes", async () => { + await runNode( + Effect.gen(function* () { + const { requestLogPath, wrapperPath } = yield* makeProviderStatusEnvFixture(); + const fileSystem = yield* FileSystem.FileSystem; + const settings = { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }; + const discover = yield* makeCursorModelDiscovery(settings, { + ...process.env, + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }); + const about = { + version: "2026.08.11", + auth: { status: "authenticated" as const, label: "first@example.test" }, + }; + const first = yield* discover(about); + expect(first.length).toBeGreaterThan(0); + yield* fileSystem.writeFileString(requestLogPath, ""); + expect(yield* discover(about)).toEqual(first); + expect(yield* fileSystem.readFileString(requestLogPath)).toBe(""); + yield* discover({ ...about, version: "2026.08.12" }); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + yield* fileSystem.writeFileString(requestLogPath, ""); + yield* discover({ + version: "2026.08.12", + auth: { ...about.auth, label: "second@example.test" }, + }); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + }), + ); + }); + it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 80668749201e..7d6d035846a1 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -10,7 +10,9 @@ import type { } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -573,6 +575,23 @@ export const discoverCursorModelsViaAcp = ( environment?: NodeJS.ProcessEnv, ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); +// Each driver instance owns its cache; version and account changes invalidate it. +export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(function* ( + cursorSettings: CursorSettings, + environment?: NodeJS.ProcessEnv, +) { + const cache = yield* Cache.makeWith( + (_key: string) => discoverCursorModelsViaAcp(cursorSettings, environment), + { + capacity: 1, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, + }, + ); + return (about: Pick) => + Cache.get(cache, JSON.stringify([about.version, about.auth])); +}); + function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { @@ -990,6 +1009,7 @@ const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: Nod export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, + discoverModels?: (about: CursorAboutResult) => ReturnType, ): Effect.fn.Return< ServerProviderDraft, never, @@ -1087,9 +1107,10 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( let discoveryWarning: string | undefined; if (parsed.auth.status !== "unauthenticated") { const discoveryExit = yield* Effect.exit( - discoverCursorModelsViaAcp(cursorSettings, environment).pipe( - Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - ), + (discoverModels + ? discoverModels(parsed) + : discoverCursorModelsViaAcp(cursorSettings, environment) + ).pipe(Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS)), ); if (Exit.isFailure(discoveryExit)) { const _dumpCauseChain = (root: unknown): string => { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 33bd21aa893b..59c4cdbf52eb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -93,6 +93,7 @@ const runtimeMock = { | ((sessionID: string) => Promise>) | null, closeCalls: [] as string[], + revertMessageID: undefined as string | undefined, revertCalls: [] as Array<{ sessionID: string; directory?: string; messageID?: string }>, messageCalls: [] as Array<{ sessionID: string; messageID: string }>, messageFailures: 0, @@ -157,6 +158,7 @@ const runtimeMock = { this.state.sessionChildrenById.clear(); this.state.sessionChildrenImplementation = null; this.state.closeCalls.length = 0; + this.state.revertMessageID = undefined; this.state.revertCalls.length = 0; this.state.messageCalls.length = 0; this.state.messageFailures = 0; @@ -300,6 +302,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { return { data: { id: sessionID, + ...(runtimeMock.state.revertMessageID + ? { revert: { messageID: runtimeMock.state.revertMessageID } } + : {}), ...(directory ? { directory } : {}), ...(parentID ? { parentID } : {}), }, @@ -425,17 +430,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ...(messageID ? { messageID } : {}), }); if (!messageID) { - runtimeMock.state.messages = []; - return; + throw new Error("Expected messageID"); + } + let lastUserID: string | undefined; + for (const entry of runtimeMock.state.messages) { + if (entry.info.role === "user") lastUserID = entry.info.id; + if (entry.info.id === messageID && entry.parts.length > 0) { + runtimeMock.state.revertMessageID = lastUserID ?? messageID; + break; + } } - - const targetIndex = runtimeMock.state.messages.findIndex( - (entry) => entry.info.id === messageID, - ); - runtimeMock.state.messages = - targetIndex >= 0 - ? runtimeMock.state.messages.slice(0, targetIndex + 1) - : runtimeMock.state.messages; }, }, event: { @@ -6456,7 +6460,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.provide(adapterLayer)); }); - it.effect("reverts the full thread when rollback removes every assistant turn", () => + it.effect("reverts the first removed assistant message and returns only retained turns", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-rollback-all"); @@ -6467,22 +6471,63 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); runtimeMock.state.messages = [ + { info: { id: "user-1", role: "user" }, parts: [] }, { info: { id: "assistant-1", role: "assistant" }, - parts: [], + parts: [{ id: "part-1", type: "text", text: "first answer" }], }, + { info: { id: "user-2", role: "user" }, parts: [] }, { info: { id: "assistant-2", role: "assistant" }, - parts: [], + parts: [{ id: "part-2", type: "text", text: "second answer" }], }, ]; - const snapshot = yield* adapter.rollbackThread(threadId, 2); - - NodeAssert.deepEqual(runtimeMock.state.revertCalls, [ - { sessionID: "http://127.0.0.1:9999/session", directory: process.cwd() }, - ]); - NodeAssert.deepEqual(snapshot.turns, []); + for (const numTurns of [0, 1, 2, 3]) { + runtimeMock.state.revertMessageID = undefined; + runtimeMock.state.revertCalls.length = 0; + const snapshot = yield* adapter.rollbackThread(threadId, numTurns); + NodeAssert.deepEqual( + runtimeMock.state.revertCalls, + numTurns === 0 + ? [] + : [ + { + sessionID: "http://127.0.0.1:9999/session", + directory: process.cwd(), + messageID: numTurns === 1 ? "assistant-2" : "assistant-1", + }, + ], + ); + NodeAssert.deepEqual( + snapshot.turns.map((turn) => turn.id), + ["assistant-1", "assistant-2"].slice(0, Math.max(0, 2 - numTurns)), + ); + } + runtimeMock.state.revertMessageID = undefined; + for (const remaining of [1, 0]) { + const snapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.equal(snapshot.turns.length, remaining); + NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, snapshot.turns); + } + NodeAssert.deepEqual( + runtimeMock.state.revertCalls.slice(-2).map((call) => call.messageID), + ["assistant-2", "assistant-1"], + ); + runtimeMock.state.revertMessageID = undefined; + runtimeMock.state.messages = runtimeMock.state.messages.filter( + (entry) => entry.info.id !== "user-2", + ); + const sharedUserSnapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.equal(runtimeMock.state.revertMessageID, "user-1"); + NodeAssert.deepEqual(sharedUserSnapshot.turns, []); + NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, []); + + runtimeMock.state.messages = []; + runtimeMock.state.revertCalls.length = 0; + const emptySnapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.deepEqual(runtimeMock.state.revertCalls, []); + NodeAssert.deepEqual(emptySnapshot.turns, []); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 1546bcfc964b..429d6850144a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -3935,6 +3935,9 @@ export function makeOpenCodeAdapter( const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( function* (threadId) { const context = yield* ensureSessionContext(sessions, threadId); + const session = yield* runOpenCodeSdk("session.get", () => + context.client.session.get({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); const messages = yield* runOpenCodeSdk("session.messages", () => context.client.session.messages({ sessionID: context.openCodeSessionId, @@ -3944,6 +3947,7 @@ export function makeOpenCodeAdapter( const turns: Array = []; for (const entry of messages.data ?? []) { + if (entry.info.id === session.data?.revert?.messageID) break; if (entry.info.role === "assistant") { turns.push({ id: TurnId.make(entry.info.id), @@ -3962,27 +3966,22 @@ export function makeOpenCodeAdapter( const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, numTurns) { const context = yield* ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - directory: context.directory, - }), - ).pipe(Effect.mapError(toRequestError)); - - const assistantMessages = (messages.data ?? []).filter( - (entry) => entry.info.role === "assistant", - ); - const targetIndex = assistantMessages.length - numTurns - 1; - const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; - yield* runOpenCodeSdk("session.revert", () => - context.client.session.revert({ - sessionID: context.openCodeSessionId, - directory: context.directory, - ...(target ? { messageID: target.info.id } : {}), - }), - ).pipe(Effect.mapError(toRequestError)); + const snapshot = yield* readThread(threadId); + const targetIndex = Math.max(0, snapshot.turns.length - numTurns); + const target = snapshot.turns[targetIndex]; + if (target) { + yield* runOpenCodeSdk("session.revert", () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + directory: context.directory, + messageID: target.id, + }), + ).pipe(Effect.mapError(toRequestError)); + // Native revert can move the boundary to the preceding user message. + return yield* readThread(threadId); + } - return yield* readThread(threadId); + return snapshot; }, ); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 93f15d1ba0ba..5a72a34218c1 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -28,6 +28,7 @@ import { buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, + deriveLockedProvider, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getAntigravitySendBlockReason, @@ -864,6 +865,113 @@ describe("resolveComposerProviderSelection", () => { ])[0]!; } + function importedThread(instanceId: ProviderInstanceId) { + return makeThread({ + modelSelection: { instanceId, model: "default" }, + messages: [ + { + id: MessageId.make(`import:${instanceId}:session:000000`), + role: "user", + text: "Continue the imported conversation", + turnId: null, + createdAt: now, + updatedAt: now, + streaming: false, + }, + ], + }); + } + + it.each([ + ["claudeAgent", "claude_work"], + ["codex", "codex_work"], + ["ollama", "local_models"], + ])("keeps imported %s history selectable through its custom instance", (driver, instanceId) => { + const importedEntry = entry(driver, instanceId); + const entries = [entry(driver === "codex" ? "claudeAgent" : "codex"), importedEntry]; + const thread = importedThread(importedEntry.instanceId); + const lockedProvider = deriveLockedProvider({ + thread, + selectedProvider: entries[0]!.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: entries.map((entry) => entry.snapshot), + }); + + expect(thread.session).toBeNull(); + expect(lockedProvider).toBe(driver); + expect( + resolveComposerProviderSelection({ + entries, + candidateInstanceIds: [thread.modelSelection.instanceId], + lockedProvider, + lockedInstanceId: thread.modelSelection.instanceId, + }).selectedProviderEntry?.instanceId, + ).toBe(importedEntry.instanceId); + }); + + it("keeps the session driver authoritative over instance and draft selections", () => { + const selected = entry("claudeAgent", "claude_work"); + const sessionEntry = entry("ollama", "local_models"); + const thread = importedThread(selected.instanceId); + + expect( + deriveLockedProvider({ + thread: { + ...thread, + session: { + ...readySession, + providerName: sessionEntry.driverKind, + providerInstanceId: sessionEntry.instanceId, + }, + }, + selectedProvider: selected.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: [selected.snapshot, sessionEntry.snapshot], + }), + ).toBe(sessionEntry.driverKind); + }); + + it.each(["missing", "disabled"] as const)( + "does not move imported history to another driver when its instance is %s", + (state) => { + const imported = entry("claudeAgent", "claude_work", { enabled: false }); + const other = entry("codex"); + const entries = state === "missing" ? [other] : [other, imported]; + const thread = importedThread(imported.instanceId); + const lockedProvider = deriveLockedProvider({ + thread, + selectedProvider: other.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: entries.map((entry) => entry.snapshot), + }); + + expect(lockedProvider).not.toBeNull(); + expect( + resolveComposerProviderSelection({ + entries, + candidateInstanceIds: [other.instanceId, imported.instanceId], + lockedProvider, + lockedInstanceId: imported.instanceId, + }).selectedProviderEntry, + ).toBeUndefined(); + }, + ); + + it("leaves a new draft free to select a different driver", () => { + const original = entry("claudeAgent", "claude_work"); + const selected = entry("codex", "codex_work"); + expect( + deriveLockedProvider({ + thread: makeThread({ + modelSelection: { instanceId: original.instanceId, model: "default" }, + }), + selectedProvider: selected.instanceId, + threadProvider: original.instanceId, + providers: [original.snapshot, selected.snapshot], + }), + ).toBeNull(); + }); + it("uses the custom instance's capability instead of the default instance", () => { const defaultEntry = entry("antigravity", "antigravity", { showInteractionModeToggle: true, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 60201fca51e0..32546bdb0e12 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -893,22 +893,13 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } -// `threadProvider` is the open branded driver kind carried by the session. -// Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe -// rollback / fork behavior — the routing layer is the right place to surface -// "driver not installed" errors, not the lock state. -// -// `selectedProvider` takes the same open-string shape because the composer -// now tracks the picker selection as a `ProviderInstanceId` (e.g. -// `codex_personal`). Custom instance ids that don't directly match a -// registered driver resolve to `null` here, which matches the existing -// "unknown driver -> unlocked" semantics. Callers that want the lock to track -// a custom instance's underlying driver kind should resolve the instance id -// upstream and pass the correlated kind. +// Imported history has no session until its first prompt. Resolve its instance +// through the environment's provider catalog before locking to a driver. export function deriveLockedProvider(input: { thread: Thread | null | undefined; selectedProvider: string | null; threadProvider: string | null; + providers: ReadonlyArray>; }): ProviderDriverKind | null { if (!threadHasStarted(input.thread)) { return null; @@ -917,14 +908,18 @@ export function deriveLockedProvider(input: { if (sessionProvider && isProviderDriverKind(sessionProvider)) { return sessionProvider; } + // Preserve the existing lock while an instance is missing from the catalog; + // a started thread must not silently fall back to a different driver. + const threadProvider = + input.providers.find((provider) => provider.instanceId === input.threadProvider)?.driver ?? + input.threadProvider; + const selectedProvider = + input.providers.find((provider) => provider.instanceId === input.selectedProvider)?.driver ?? + input.selectedProvider; const narrowedThreadProvider = - input.threadProvider && isProviderDriverKind(input.threadProvider) - ? input.threadProvider - : null; + threadProvider && isProviderDriverKind(threadProvider) ? threadProvider : null; const narrowedSelectedProvider = - input.selectedProvider && isProviderDriverKind(input.selectedProvider) - ? input.selectedProvider - : null; + selectedProvider && isProviderDriverKind(selectedProvider) ? selectedProvider : null; return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ff4d9a72e912..0bc32daf717f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -118,6 +118,7 @@ import { type LegendListRef } from "@legendapp/list/react"; import { CHAT_TIMELINE_ANCHOR_OFFSET, getAnchoredTurnMetrics, + timelineContentOverflowsViewport, type TimelineScrollMode, } from "./chat/timelineScrollAnchoring"; import { @@ -1742,6 +1743,9 @@ export default function ChatView(props: ChatViewProps) { const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); + // Whether the timeline's rows extend past the viewport above the composer. + // The composer only rests when there is reading space to give back. + const [timelineOverflows, setTimelineOverflows] = useState(false); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); @@ -2427,6 +2431,12 @@ export default function ChatView(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); + // Once a thread selects an environment, never substitute the primary + // environment's config while the selected environment is still loading. + const serverConfig = activeThread + ? (activeEnvironment?.serverConfig ?? null) + : (primaryEnvironment?.serverConfig ?? null); + const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? @@ -2436,12 +2446,8 @@ export default function ChatView(props: ChatViewProps) { thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, + providers: providerStatuses, }); - // Once a thread selects an environment, never substitute the primary - // environment's config while the selected environment is still loading. - const serverConfig = activeThread - ? (activeEnvironment?.serverConfig ?? null) - : (primaryEnvironment?.serverConfig ?? null); const pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; @@ -2655,7 +2661,6 @@ export default function ChatView(props: ChatViewProps) { versionMismatchThreadContinuation, versionMismatchServerLabel, ]); - const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const providerInstanceEntries = useMemo( () => sortProviderInstanceEntries( @@ -4801,32 +4806,11 @@ export default function ChatView(props: ChatViewProps) { [composerTimelineInset], ); const timelineRealContentOverflowsViewport = useCallback( - (list?: LegendListRef | null) => { - const resolvedList = list ?? legendListRef.current; - const state = resolvedList?.getState(); - if (!resolvedList || !state || state.data.length === 0) { - return false; - } - - const lastRowIndex = state.data.length - 1; - const lastRowTop = state.positionAtIndex(lastRowIndex); - const lastRowHeight = state.sizeAtIndex(lastRowIndex); - if ( - typeof lastRowTop !== "number" || - typeof lastRowHeight !== "number" || - !Number.isFinite(lastRowTop) || - !Number.isFinite(lastRowHeight) - ) { - return false; - } - - const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); - const visibleScrollLength = Math.max( - 0, - (state.scrollLength ?? 0) - composerTimelineInset - CHAT_TIMELINE_ANCHOR_OFFSET, - ); - return realContentBottom > visibleScrollLength; - }, + (list?: LegendListRef | null) => + timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), { + composerInset: composerTimelineInset, + anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, + }), [composerTimelineInset], ); const pageScrollControllerRef = useRef | null>( @@ -8597,6 +8581,7 @@ export default function ChatView(props: ChatViewProps) { contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} + onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} @@ -8755,6 +8740,7 @@ export default function ChatView(props: ChatViewProps) { onRestingControlsVisibilityChange={setRestingComposerControlsVisible} getTimelineScrollableNode={getTimelineScrollableNode} isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} + timelineOverflows={timelineOverflows} onComposerOverlayHeightChange={publishComposerOverlayHeight} onRestingChange={onComposerRestingChange} promptRef={promptRef} diff --git a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx new file mode 100644 index 000000000000..980521084f8c --- /dev/null +++ b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx @@ -0,0 +1,184 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { $copyNode, $getRoot, $isElementNode, PASTE_COMMAND, type LexicalEditor } from "lexical"; +import { act, createRef } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { collapseExpandedComposerCursor } from "../composer-logic"; +import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; + +vi.mock("./chat/FileTagChip", () => ({ + FILE_TAG_CHIP_CLASS_NAME: "", + FileTagChipContent: () => null, +})); +vi.mock("./chat/ComposerPendingTerminalContexts", () => ({ + ComposerPendingTerminalContextChip: () => null, +})); +vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null })); + +let lexicalEditor: LexicalEditor; +// Keep the real composer, registered nodes, updates, and snapshot API. Only the +// DOM view is omitted so Lexical runs headlessly in this component test. +vi.mock("@lexical/react/LexicalPlainTextPlugin", () => ({ + PlainTextPlugin: function HeadlessEditor() { + [lexicalEditor] = useLexicalComposerContext(); + return null; + }, +})); + +let renderer: ReactTestRenderer | undefined; +const editorRef = createRef(); + +function composer(value: string) { + return ( + {}} + onChange={() => {}} + onPaste={() => {}} + editorRef={editorRef} + /> + ); +} + +async function renderPrompt(value: string) { + await act(() => { + if (renderer) renderer.update(composer(value)); + else renderer = create(composer(value)); + }); +} + +function $firstMention() { + const paragraph = $getRoot().getFirstChildOrThrow(); + if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); + const mention = paragraph.getFirstChildOrThrow(); + if (mention.getType() !== "composer-mention") throw new Error("Expected a mention"); + return mention; +} + +class TestClipboardEvent extends Event { + readonly clipboardData: DataTransfer; + + constructor(text: string) { + super("paste", { cancelable: true }); + this.clipboardData = { + files: [], + getData: (type: string) => (type === "text/plain" ? text : ""), + } as unknown as DataTransfer; + } +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", { activeElement: null }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("composer mention serialization", () => { + it.each([ + "@README.md control", + "@terminal-1:3 Explain this output\n\n\n- Terminal 1 line 3:\n 3 | output\n", + '@"docs/My \\"File\\".md" please', + '@"docs/雪 👋.md" please', + "[README.md](README.md) control", + "[config#draft?.json](config%23draft%3f.json) control", + "Plain text\n Keep indentation 👋", + ])("preserves the initial prompt %s", async (prompt) => { + await renderPrompt(prompt); + expect(editorRef.current?.readSnapshot().value).toBe(prompt); + }); + + it("preserves original source when replacing the controlled prompt", async () => { + for (const prompt of ["", "@README.md control", "Older plain control", "@README.md control"]) { + await renderPrompt(prompt); + expect(editorRef.current?.readSnapshot()).toMatchObject({ + value: prompt, + expandedCursor: prompt.length, + }); + if (prompt === "@README.md control") { + expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); + } + } + }); + + it("preserves source when Lexical clones the mention and reloads exported state", async () => { + const prompt = '@"docs/雪 👋.md" remains a chip'; + await renderPrompt(prompt); + const originalKey = lexicalEditor.getEditorState().read(() => $firstMention().getKey()); + + await act(() => { + lexicalEditor.update( + () => { + const mention = $firstMention(); + mention.replace($copyNode(mention)); + }, + { discrete: true }, + ); + }); + expect(lexicalEditor.getEditorState().read(() => $firstMention().getKey())).not.toBe( + originalKey, + ); + expect(editorRef.current?.readSnapshot().value).toBe(prompt); + const exportedState = lexicalEditor.getEditorState().toJSON(); + + await renderPrompt(""); + await act(() => { + lexicalEditor.setEditorState(lexicalEditor.parseEditorState(exportedState)); + }); + expect(editorRef.current?.readSnapshot().value).toBe(prompt); + expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); + }); + + it("keeps canonical serialization when importing legacy mention JSON without source", async () => { + await renderPrompt(""); + await act(() => { + lexicalEditor.setEditorState( + lexicalEditor.parseEditorState( + JSON.stringify({ + root: { + type: "root", + version: 1, + children: [ + { + type: "paragraph", + version: 1, + children: [{ type: "composer-mention", version: 1, path: "README.md" }], + }, + ], + }, + }), + ), + ); + }); + expect(editorRef.current?.readSnapshot().value).toBe("[README.md](README.md)"); + expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); + }); + + it("still serializes a newly inserted mention canonically", async () => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + await renderPrompt(""); + const event = new TestClipboardEvent("@README.md "); + await act(() => { + lexicalEditor.update( + () => { + $getRoot().selectEnd(); + lexicalEditor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ); + }); + expect(event.defaultPrevented).toBe(true); + expect(editorRef.current?.readSnapshot().value).toBe("[README.md](README.md) "); + expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); + }); +}); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 2ecb4c873e88..6b49ef884734 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -117,6 +117,7 @@ const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; type SerializedComposerMentionNode = Spread< { path: string; + source?: string; type: "composer-mention"; version: 1; }, @@ -174,28 +175,33 @@ function ComposerMentionDecorator(props: { path: string }) { class ComposerMentionNode extends DecoratorNode { __path: string; + __source: string; static override getType(): string { return "composer-mention"; } static override clone(node: ComposerMentionNode): ComposerMentionNode { - return new ComposerMentionNode(node.__path, node.__key); + return new ComposerMentionNode(node.__path, node.__source, node.__key); } static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { - return $createComposerMentionNode(serializedNode.path).updateFromJSON(serializedNode); + return $createComposerMentionNode(serializedNode.path, serializedNode.source).updateFromJSON( + serializedNode, + ); } - constructor(path: string, key?: NodeKey) { + constructor(path: string, source = serializeComposerFileLink(path), key?: NodeKey) { super(key); this.__path = path; + this.__source = source; } override exportJSON(): SerializedComposerMentionNode { return { ...super.exportJSON(), path: this.__path, + source: this.__source, type: "composer-mention", version: 1, }; @@ -212,7 +218,7 @@ class ComposerMentionNode extends DecoratorNode { } override getTextContent(): string { - return serializeComposerFileLink(this.__path); + return this.__source; } override isInline(): true { @@ -224,8 +230,8 @@ class ComposerMentionNode extends DecoratorNode { } } -function $createComposerMentionNode(path: string): ComposerMentionNode { - return $applyNodeReplacement(new ComposerMentionNode(path)); +function $createComposerMentionNode(path: string, source?: string): ComposerMentionNode { + return $applyNodeReplacement(new ComposerMentionNode(path, source)); } function resolveSkillDescription( @@ -851,7 +857,7 @@ function $setComposerEditorPrompt( continue; } if (segment.type === "mention") { - paragraph.append($createComposerMentionNode(segment.path)); + paragraph.append($createComposerMentionNode(segment.path, segment.source)); continue; } if (segment.type === "skill") { diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 4db48cac5bc7..73dbbad7f13a 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -212,7 +212,10 @@ import { SidebarMenuSubItem, useSidebar, } from "./ui/sidebar"; -import { useThreadSelectionStore } from "../threadSelectionStore"; +import { + getThreadKeysToDeselectAfterDelete, + useThreadSelectionStore, +} from "../threadSelectionStore"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { subscribeToProjectReveal } from "../projectJump"; import { @@ -2159,26 +2162,35 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!confirmed) return; } - const deletedThreadKeys = new Set(threadKeys); - for (const { threadRef } of selectedThreadEntries) { + // Only discount batch members after their deletions succeed. + const deletedThreadKeys = new Set(); + let firstError: unknown = null; + for (const { threadKey, threadRef } of selectedThreadEntries) { const result = await deleteThread(threadRef, { deletedThreadKeys, }); if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; + if (isAtomCommandInterrupted(result)) break; + firstError ??= squashAtomCommandFailure(result); + continue; } + deletedThreadKeys.add(threadKey); } - removeFromSelection(threadKeys); + if (firstError !== null) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); + } + removeFromSelection( + getThreadKeysToDeselectAfterDelete(threadKeys, deletedThreadKeys, (threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef !== null && readThreadShell(threadRef) !== null; + }), + ); }, [ appSettingsConfirmThreadArchive, @@ -6004,11 +6016,9 @@ export default function LegacySidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; - const commandPaletteShortcutLabel = shortcutLabelForCommand( - keybindings, - "commandPalette.toggle", - newThreadShortcutLabelOptions, - ); + const commandPaletteShortcutLabel = isMobile + ? null + : shortcutLabelForCommand(keybindings, "commandPalette.toggle", newThreadShortcutLabelOptions); const handleDesktopUpdateButtonClick = useCallback(async () => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 851367b2ed60..9b2c92a14103 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -29,6 +29,7 @@ import { import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { + parseScopedThreadKey, scopeProjectRef, scopeThreadRef, scopedThreadKey, @@ -109,7 +110,10 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; -import { useThreadSelectionStore } from "../threadSelectionStore"; +import { + getThreadKeysToDeselectAfterDelete, + useThreadSelectionStore, +} from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; @@ -120,6 +124,7 @@ import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { + readThreadShell, useAllEnvironmentProjectSnapshotsReady, useProjects, useThreadShells, @@ -395,7 +400,7 @@ function SidebarThreadTooltip({ className="max-w-80 text-left whitespace-normal [&_[data-slot=tooltip-viewport]]:p-0" >
-
+
{thread.title}
@@ -3232,8 +3237,9 @@ export default function Sidebar() { // right now. Selections can outlive their rows (settled-tail paging, // thread deletion elsewhere) and the menu labels must count only what // the actions will touch. - const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( - (threadKey) => threadByKeyRef.current.has(threadKey), + const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + const threadKeys = selectedThreadKeys.filter((threadKey) => + threadByKeyRef.current.has(threadKey), ); if (threadKeys.length === 0) return; const count = threadKeys.length; @@ -3429,6 +3435,7 @@ export default function Sidebar() { // really gone, or the first delete would treat still-alive batch mates // as deleted and remove a worktree they still point at. const deletedThreadKeys = new Set(); + let firstError: unknown = null; for (const threadKey of threadKeys) { const thread = threadByKeyRef.current.get(threadKey); if (!thread) continue; @@ -3436,21 +3443,27 @@ export default function Sidebar() { deletedThreadKeys, }); if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; + if (isAtomCommandInterrupted(result)) break; + firstError ??= squashAtomCommandFailure(result); + continue; } deletedThreadKeys.add(threadKey); } - removeFromSelection(threadKeys); + if (firstError !== null) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); + } + removeFromSelection( + getThreadKeysToDeselectAfterDelete(selectedThreadKeys, deletedThreadKeys, (threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef !== null && readThreadShell(threadRef) !== null; + }), + ); }, [ attemptSettle, diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index eca741af8c89..c34eec58316d 100644 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.test.tsx @@ -4,9 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, - resolveSidebarStageFocusRingOffsetClass, StageBackdropArt, - StageBackdropButtonArt, } from "./SidebarStageBackdrop"; describe("SidebarStageBackdrop", () => { @@ -24,15 +22,6 @@ describe("SidebarStageBackdrop", () => { expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull(); }); - it("matches the focus-ring offset to each artwork palette", () => { - expect(resolveSidebarStageFocusRingOffsetClass("nightly")).toBe( - "focus-visible:ring-offset-(--stage-night-bottom)", - ); - expect(resolveSidebarStageFocusRingOffsetClass("dev")).toBe( - "focus-visible:ring-offset-(--stage-art-bottom)", - ); - }); - it.each(["nightly", "dev"] as const)( "uses unique SVG definition ids when %s artwork is rendered more than once", (variant) => { @@ -48,26 +37,4 @@ describe("SidebarStageBackdrop", () => { expect(new Set(ids).size).toBe(ids.length); }, ); - - it("paints each artwork variant with theme-owned color tokens", () => { - const nightlyMarkup = renderToStaticMarkup(); - const devMarkup = renderToStaticMarkup(); - - expect(nightlyMarkup).toContain("var(--stage-night-bottom)"); - expect(nightlyMarkup).toContain("var(--stage-night-line)"); - expect(devMarkup).toContain("var(--stage-art-bottom)"); - expect(devMarkup).toContain("var(--stage-art-line)"); - expect(nightlyMarkup).not.toMatch(/#[0-9a-f]{3,8}/i); - expect(devMarkup).not.toMatch(/#[0-9a-f]{3,8}/i); - }); - - it.each([ - ["nightly", "96 0 8192 96"], - ["dev", "64 0 8192 96"], - ] as const)("uses the compact %s crop inside the send button", (variant, viewBox) => { - const markup = renderToStaticMarkup(); - - expect(markup).toContain(`viewBox="${viewBox}"`); - expect(markup).toContain(`stage-${variant === "dev" ? "blueprint" : "nightly"}`); - }); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a6976d39547f..30f5c6191118 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1267,6 +1267,8 @@ export interface ChatComposerProps { onRestingControlsVisibilityChange: (visible: boolean) => void; getTimelineScrollableNode: () => HTMLElement | null; isTimelineAtLogicalEnd: () => boolean; + /** Whether the timeline has more content than fits above the composer. */ + timelineOverflows: boolean; onComposerOverlayHeightChange: (height: number) => void; /** * Whether the desktop resting layout is active. Reported from a layout @@ -1407,6 +1409,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onRestingControlsVisibilityChange, getTimelineScrollableNode, isTimelineAtLogicalEnd, + timelineOverflows, onComposerOverlayHeightChange, onRestingChange, promptRef, @@ -3834,6 +3837,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isScrollCollapsed: isComposerScrollCollapsed, hasExpandedChrome: composerHasExpandedChrome, collapseOnBlur: settings.composerCollapseOnBlur, + timelineOverflows, }); // The relocated controls live in the context strip whenever the composer is // collapsed for any reason, the desktop resting layout or the phone diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index ddcf0977af75..27a43ecf7944 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -281,6 +281,7 @@ describe("MessagesTimeline", () => { isScrollCollapsed: composer.isComposerScrollCollapsed, hasExpandedChrome: false, collapseOnBlur: true, + timelineOverflows: true, }); }); return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2427e1131ffc..759a2a538ea7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -113,7 +113,10 @@ import { } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; -import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring"; +import { + CHAT_TIMELINE_ANCHOR_OFFSET, + timelineContentOverflowsViewport, +} from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; @@ -338,6 +341,11 @@ interface MessagesTimelineProps { */ liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; + /** + * Whether the real rows extend past the viewport above the composer. + * Reported after scrolls, row size changes, and viewport resizes. + */ + onContentOverflowChange?: (overflows: boolean) => void; onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; @@ -386,6 +394,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, + onContentOverflowChange, onToolOutputCollapsedAtEnd, onManualNavigation, hideEmptyPlaceholder = false, @@ -606,12 +615,49 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [anchoredEndSpace, contentInsetEndAdjustment], ); + const measureContentOverflow = useCallback( + () => + timelineContentOverflowsViewport(listRef.current?.getState?.(), { + composerInset: contentInsetEndAdjustment, + anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, + }), + [contentInsetEndAdjustment, listRef], + ); + // LegendList lays rows out from layout effects, so a read on the next frame + // sees the settled positions. One frame is shared across bursts of size + // changes. + const contentOverflowFrameRef = useRef(null); + const cancelContentOverflowFrame = useCallback(() => { + if (contentOverflowFrameRef.current !== null) { + cancelAnimationFrame(contentOverflowFrameRef.current); + contentOverflowFrameRef.current = null; + } + }, []); + const reportContentOverflow = useCallback(() => { + if (!onContentOverflowChange || contentOverflowFrameRef.current !== null) return; + contentOverflowFrameRef.current = requestAnimationFrame(() => { + contentOverflowFrameRef.current = null; + onContentOverflowChange(measureContentOverflow()); + }); + }, [measureContentOverflow, onContentOverflowChange]); + useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]); + // The list's own layout effects have already run here, so estimated row + // positions are in place. Reporting before the first paint lets a thread + // open in its final composer layout instead of correcting it a frame later. + // A frame scheduled with the previous inset would overwrite this read, so + // it is dropped first. + useLayoutEffect(() => { + cancelContentOverflowFrame(); + onContentOverflowChange?.(measureContentOverflow()); + }, [cancelContentOverflowFrame, measureContentOverflow, onContentOverflowChange, rows.length]); + const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state); if (isAtEnd !== undefined && !citationPositioning) { onIsAtEndChange(isAtEnd); } + reportContentOverflow(); if (!state || minimapItems.length === 0) { return; } @@ -634,7 +680,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [citationPositioning, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [ + citationPositioning, + listRef, + minimapItems, + minimapStripMap, + onIsAtEndChange, + reportContentOverflow, + ]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -653,6 +706,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); + reportContentOverflow(); }; const frame = requestAnimationFrame(measure); @@ -664,7 +718,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ cancelAnimationFrame(frame); observer.disconnect(); }; - }, [timelineViewportElement, rows.length]); + }, [timelineViewportElement, rows.length, reportContentOverflow]); const sharedState = useMemo( () => ({ @@ -810,6 +864,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } maintainScrollAtEndThreshold={1} onScroll={handleScroll} + onItemSizeChanged={reportContentOverflow} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "topbar-scroll-fade", @@ -3205,7 +3260,7 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time