From e38c2e0add11cf0850dbcb45a41b9a71eaafe734 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 11 Sep 2026 01:34:54 -0600 Subject: [PATCH 01/10] fix(web): prevent chat metadata overlap Long project names and locked workspace labels no longer push the thread title or context strip controls over each other. The locked workspace label now truncates and compacts like the selectable one, the workspace controls keep a minimum width, and label overflow is measured before paint. Pylon keeps the branch selector in the left run beside the workspace controls, so upstream's branch flex-basis hunk has no equivalent here; the overflow measurement loop already skipped zero-width children. Adopted from f47e74004af232f0e3df8dc10093601d1c2c3ea3 (#8851) --- apps/web/src/components/BranchToolbar.tsx | 4 ++-- .../BranchToolbarEnvModeSelector.tsx | 21 ++++++++++++------- apps/web/src/components/chat/ChatHeader.tsx | 11 ++++++---- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index adde26075..eff5ccfb5 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -427,7 +427,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { // Label widths can change without the strip box moving (font family or // size preferences), so re-measure on every render as well as on resize // and font loads. - useEffect(() => { + useLayoutEffect(() => { measure(); }); @@ -590,7 +590,7 @@ export const BranchToolbar = memo(function BranchToolbar({ ) : null}
diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6c7a61c8d..4685dbf1e 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -56,16 +56,21 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe data-composer-context-control > {activeWorktreePath ? ( - <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} - + ) : ( - <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} - + )} + + + {resolveLockedWorkspaceLabel(activeWorktreePath)} + + ); } diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 59e027c14..fbebc323a 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -317,13 +317,16 @@ export const ChatHeader = memo(function ChatHeader({ className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" onContextMenu={handleHeaderContextMenu} > - + {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone doesn't answer it. */} {activeProject ? ( <> - + } > @@ -344,7 +347,7 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} - + {renamingTitle !== null ? ( Date: Fri, 11 Sep 2026 01:35:21 -0600 Subject: [PATCH 02/10] fix(mobile): clip the active tool row shimmer to its row The shimmer sweep on an active tool row now fills its masked layer with StyleSheet.absoluteFill and the row clips overflow, so the highlight stays inside the label instead of drawing past it. Partially adopted: the shimmer derivation change (shimmer follows live) was later rewritten by #10173 and #10273, which Pylon already carries. Adopted from 5ce92c2f192040bf77c0211fa33bf03c74c031ef (#8932) --- apps/mobile/src/features/threads/thread-work-log.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index ec4c3deaf..9750e8428 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -268,7 +268,7 @@ export function ShimmeringWorkContent(props: { return ( setAvailableWidth(event.nativeEvent.layout.width)} > From fc824988181164e5e68026892dd164bafa902aaa Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 11 Sep 2026 01:37:12 -0600 Subject: [PATCH 03/10] fix(server): isolate remote web session cookies Remote production web servers on the same hostname all used the `t3_session` cookie, and browsers do not scope cookies by port, so one server could overwrite another's browser session. Remote web cookies now take their name from the persisted environment ID, which survives state-directory moves and stays distinct for environments that share an internal path. A valid legacy `t3_session` cookie still authenticates, at the lowest precedence, and the session endpoint migrates it to the new name. Environment identity is split from the full descriptor so `t3 auth`, `t3 pair`, `t3 project` and `t3 connect` load the saved ID without launcher checks. Initialization publishes the ID atomically and repairs an empty ID file through a retained recovery file. Desktop and development cookie names are unchanged. Adopted from c78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc (#8085) --- apps/server/src/auth/EnvironmentAuth.test.ts | 16 +++ apps/server/src/auth/EnvironmentAuth.ts | 50 +++++++-- .../src/auth/EnvironmentAuthAdmin.test.ts | 2 + .../src/auth/EnvironmentAuthPolicy.test.ts | 6 +- apps/server/src/auth/EnvironmentAuthPolicy.ts | 3 + apps/server/src/auth/SessionStore.test.ts | 37 ++++++- apps/server/src/auth/SessionStore.ts | 12 ++- apps/server/src/auth/http.ts | 49 ++++++--- apps/server/src/auth/utils.test.ts | 65 +++++++---- apps/server/src/auth/utils.ts | 35 ++++-- apps/server/src/bin.test.ts | 29 ++++- apps/server/src/cli/connect.ts | 5 +- apps/server/src/cli/pair.test.ts | 25 ++++- .../src/environment/ServerEnvironment.test.ts | 77 ++++++++++++- .../src/environment/ServerEnvironment.ts | 102 ++++++++++++++---- apps/server/src/server.test.ts | 47 ++++++++ apps/server/src/server.ts | 7 +- docs/internals/remote.md | 4 + 18 files changed, 484 insertions(+), 87 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 01bac08db..028fe53e0 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -34,6 +35,7 @@ const makeEnvironmentAuthLayer = (overrides?: Partial { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("prefers a bearer token over a stale legacy cookie", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const bearer = yield* serverAuth.issueSession(); + const verified = yield* serverAuth.authenticateHttpRequest({ + cookies: { [sessions.legacyCookieName ?? "t3_session"]: "stale" }, + headers: { authorization: `Bearer ${bearer.token}` }, + } as never); + + expect(verified.sessionId).toBe(bearer.sessionId); + }).pipe(Effect.provide(makeEnvironmentAuthLayer({ mode: "web", host: "192.168.1.50" }))), + ); + it.effect("does not exchange ordinary pairing grants for administrative access tokens", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index b33e6c2e7..be7f0eed4 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -30,6 +30,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -562,6 +563,34 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | return token.length > 0 ? token : null; } +export function selectRequestCredential( + request: HttpServerRequest.HttpServerRequest, + cookieName: string, + legacyCookieName: string | undefined, +) { + const cookieToken = request.cookies[cookieName]; + if (cookieToken !== undefined) { + return { token: cookieToken, source: "cookie" } as const; + } + + const bearerToken = parseBearerToken(request); + if (bearerToken !== null) { + return { token: bearerToken, source: "bearer" } as const; + } + + const dpopToken = parseDpopToken(request); + if (dpopToken !== null) { + return { token: dpopToken, source: "dpop" } as const; + } + + const legacyToken = legacyCookieName ? request.cookies[legacyCookieName] : undefined; + if (legacyToken !== undefined) { + return { token: legacyToken, source: "legacy-cookie" } as const; + } + + return undefined; +} + export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -600,17 +629,19 @@ export const make = Effect.gen(function* () { const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, ): Effect.Effect => { - const cookieToken = request.cookies[sessions.cookieName]; - const bearerToken = parseBearerToken(request); - const dpopToken = parseDpopToken(request); - const credential = cookieToken ?? bearerToken ?? dpopToken; - if (!credential) { + const credential = selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } - return authenticateToken(credential).pipe( + const dpopToken = parseDpopToken(request); + return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { - if (!dpopToken || dpopToken !== credential) { + if (!dpopToken || dpopToken !== credential.token) { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound access token requires DPoP authorization.", @@ -1006,4 +1037,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe( export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); -export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer)); +export const runtimeLayer = layer.pipe( + Layer.provideMerge(storageLayer), + Layer.provideMerge(ServerEnvironment.identityLayer), +); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 4859b57cc..d8a0e3048 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -35,6 +36,7 @@ const makeEnvironmentAuthLayer = ( EnvironmentAuth.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(makeServerConfigLayer(overrides)), ); diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 8e4c21710..982ff397d 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -4,12 +4,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; const makeEnvironmentAuthPolicyLayer = ( overrides?: Partial, ) => EnvironmentAuthPolicy.layer.pipe( + Layer.provide(ServerEnvironment.identityLayer), Layer.provide( Layer.effect( ServerConfig.ServerConfig, @@ -107,7 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("remote-reachable"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ @@ -143,7 +145,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { const descriptor = yield* policy.getDescriptor(); expect(descriptor.policy).toBe("remote-reachable"); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 9945c6906..446b8a8bb 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; export class EnvironmentAuthPolicy extends Context.Service< @@ -15,6 +16,7 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const isRemoteReachable = isRemoteReachableHost(config.host); const policy = @@ -42,6 +44,7 @@ export const make = Effect.gen(function* () { port: config.port, host: config.host, instanceKey: config.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: config.devUrl !== undefined, }), }; diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1453caaf3..1e2d5c60e 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -10,15 +11,14 @@ import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -const makeServerConfigLayer = ( - overrides?: Partial>, -) => +const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( ServerConfig.ServerConfig, Effect.gen(function* () { @@ -30,12 +30,19 @@ const makeServerConfigLayer = ( }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); +const makeServerEnvironmentLayer = (environmentId: EnvironmentId) => + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(environmentId), + }); + const makeSessionStoreLayer = ( - overrides?: Partial>, + overrides?: Partial, + environmentId = EnvironmentId.make("test-environment"), ) => SessionStore.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide(makeServerEnvironmentLayer(environmentId)), Layer.provide(makeServerConfigLayer(overrides)), ); @@ -70,10 +77,32 @@ const failingSessionLookupCredentialLayer = Layer.effect( Layer.provide(failingSessionLookupRepositoryLayer), Layer.provide(ServerSecretStore.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))), Layer.provide(makeServerConfigLayer()), ); it.layer(NodeServices.layer)("SessionStore.layer", (it) => { + it.effect("keys remote cookies by environment identity instead of state directory", () => + Effect.gen(function* () { + const cookieName = (stateDir: string, environmentId: EnvironmentId) => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + return sessions.cookieName; + }).pipe( + Effect.provide( + makeSessionStoreLayer({ mode: "web", host: "192.168.1.50", stateDir }, environmentId), + ), + ); + + const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one")); + const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one")); + const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two")); + + expect(moved).toBe(original); + expect(other).not.toBe(original); + }), + ); + it.effect("issues and verifies signed browser session tokens", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index f8ea9550e..f4e5e3c48 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -21,11 +21,13 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, + resolveLegacySessionCookieName, resolveSessionCookieName, signPayload, timingSafeEqualBase64Url, @@ -360,6 +362,7 @@ export class SessionStore extends Context.Service< SessionStore, { readonly cookieName: string; + readonly legacyCookieName: string | undefined; readonly issue: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; @@ -475,18 +478,22 @@ function toAuthClientSession(input: Omit): AuthCli export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const secretStore = yield* ServerSecretStore.ServerSecretStore; const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); - const cookieName = resolveSessionCookieName({ + const cookieInput = { mode: serverConfig.mode, port: serverConfig.port, host: serverConfig.host, instanceKey: serverConfig.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: serverConfig.devUrl !== undefined, - }); + } as const; + const cookieName = resolveSessionCookieName(cookieInput); + const legacyCookieName = resolveLegacySessionCookieName(cookieInput); const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { @@ -959,6 +966,7 @@ export const make = Effect.gen(function* () { return SessionStore.of({ cookieName, + legacyCookieName, issue, verify, issueWebSocketToken, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 58277141a..cc74966c4 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, }); } +const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) => + Effect.fromResult( + Cookies.set(Cookies.empty, cookieName, token, { + expires: DateTime.toDate(expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe( + Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")), + Effect.flatMap((cookies) => + HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.mergeCookies(response, cookies)), + ), + ), + ); + export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( scope: AuthEnvironmentScope, ) { @@ -224,7 +241,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); const request = yield* HttpServerRequest.HttpServerRequest; - return yield* serverAuth.getSessionState(request); + const result = yield* serverAuth.getSessionState(request); + const credential = EnvironmentAuth.selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if ( + credential?.source === "legacy-cookie" && + result.authenticated && + result.sessionMethod === "browser-session-cookie" && + result.expiresAt + ) { + yield* appendSessionCookie(sessions.cookieName, credential.token, result.expiresAt); + yield* appendCredentialResponseHeaders; + } + return result; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -241,17 +273,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); - const sessionCookies = yield* Effect.fromResult( - Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { - expires: DateTime.toDate(result.response.expiresAt), - httpOnly: true, - path: "/", - sameSite: "lax", - }), - ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); - - yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), + yield* appendSessionCookie( + sessions.cookieName, + result.sessionToken, + result.response.expiresAt, ); yield* appendCredentialResponseHeaders; return result.response; diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 47ecc3ec5..75bc4bcdf 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -64,6 +64,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-one", + environmentId: "environment-one", development: true, }); const second = resolveSessionCookieName({ @@ -71,6 +72,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-two", + environmentId: "environment-two", development: true, }); @@ -79,25 +81,48 @@ describe("session cookie isolation", () => { expect(first).not.toBe(second); }); - it("keeps the hosted web cookie stable across server instances", () => { - expect( - resolveSessionCookieName({ - mode: "web", - port: 8080, - host: "0.0.0.0", - instanceKey: "/srv/release-a", - development: false, - }), - ).toBe("t3_session"); - expect( - resolveSessionCookieName({ - mode: "web", - port: 9090, - host: "app.example.com", - instanceKey: "/srv/release-b", - development: false, - }), - ).toBe("t3_session"); + it("isolates remote web servers by server state", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 3773, + host: "192.168.1.50", + instanceKey: "/srv/t3-one", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "192.168.1.50", + instanceKey: "/srv/t3-two", + environmentId: "environment-two", + development: false, + }); + + expect(first).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(second).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(first).not.toBe(second); + }); + + it("keeps a remote web server cookie stable across port changes", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 8080, + host: "0.0.0.0", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 9090, + host: "app.example.com", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + + expect(first).toBe(second); }); it("retains desktop port scoping", () => { @@ -107,6 +132,7 @@ describe("session cookie isolation", () => { port: 3773, host: "127.0.0.1", instanceKey: "/tmp/desktop", + environmentId: "environment-one", development: true, }), ).toBe("t3_session_3773"); @@ -119,6 +145,7 @@ describe("session cookie isolation", () => { port: 5775, host: "0.0.0.0", instanceKey: "/tmp/t3-wildcard-dev", + environmentId: "environment-one", development: true, }), ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 32a6799b0..30d59d654 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -16,40 +16,53 @@ const SESSION_COOKIE_NAME = "t3_session"; * clobbers the first's session and both sides see "Invalid session token * signature" until someone clears cookies by hand. * - * Two populations qualify, for the same reason but from different causes: + * Remote web servers use their persisted environment identity and omit the + * port, so the name survives state-directory moves and public port changes. * - * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. - * - **Desktop**, which scans upward from 3773 for a free port and binds + * Desktop scans upward from 3773 for a free port and binds * 127.0.0.1, so a second instance lands on a different port and the same host. - * - * Hosted deployments keep the stable production name: their public port can - * change between releases, and scoping it would log every user out. */ export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; readonly host: string | undefined; readonly instanceKey: string; + readonly environmentId: string; readonly development: boolean; }): string { if (input.mode === "desktop") { return `${SESSION_COOKIE_NAME}_${input.port}`; } + const instanceHash = NodeCrypto.createHash("sha256") + .update( + !input.development && isRemoteReachableHost(input.host) + ? input.environmentId + : input.instanceKey, + ) + .digest("hex") + .slice(0, 12); + if (!input.development && isRemoteReachableHost(input.host)) { - return SESSION_COOKIE_NAME; + return `${SESSION_COOKIE_NAME}_${instanceHash}`; } // Cookies are scoped by host, not port. Loopback development servers need an // instance-specific name or parallel agents overwrite each other's session, // and a server that later reuses the port receives a token signed elsewhere. - const instanceHash = NodeCrypto.createHash("sha256") - .update(input.instanceKey) - .digest("hex") - .slice(0, 12); return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; } +export function resolveLegacySessionCookieName(input: { + readonly mode: "web" | "desktop"; + readonly host: string | undefined; + readonly development: boolean; +}): string | undefined { + return input.mode === "web" && !input.development && isRemoteReachableHost(input.host) + ? SESSION_COOKIE_NAME + : undefined; +} + export function isRemoteReachableHost(host: string | undefined): boolean { if (host === "0.0.0.0" || host === "::" || host === "[::]") { return true; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index de93078a7..873037ca3 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -14,6 +14,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -27,7 +28,13 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; +import { + SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_LAUNCHER_PROTOCOL, +} from "./cloud/serviceProtocol.ts"; import * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; @@ -43,7 +50,24 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import packageJson from "../package.json" with { type: "json" }; + const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const DisconnectedLauncherChildLayer = Layer.mergeAll( + Layer.succeed(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Layer.succeed(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), +); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); @@ -347,6 +371,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef Layer.provideMerge( EnvironmentAuth.layer.pipe( Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(ServerSecretStore.layer), ), ), @@ -465,7 +490,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(status.linked, false); assert.equal(status.cloudUserId, null); assert.equal(status.relayUrl, null); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("reports actionable human-readable headless connect state", () => @@ -636,7 +661,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { "relay:write", ]); assert.equal("token" in (listed[0] ?? {}), false); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("rejects invalid ttl values before running auth commands", () => diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index c4aba1e3c..00b61c1c4 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -336,7 +336,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f return { status: "not-authenticated" } satisfies RelayUnlinkResult; } - const environment = yield* ServerEnvironment.ServerEnvironment; + const environment = yield* ServerEnvironment.ServerEnvironmentIdentity; const environmentId = yield* environment.getEnvironmentId; const relayUrl = yield* relayUrlConfig; const httpClient = yield* HttpClient.HttpClient; @@ -431,7 +431,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* , options?: { readonly quietLogs?: boolean; @@ -448,7 +448,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* { assert.equal(credentials.length, 1); assert.equal(credentials[0]?.label, "t3 pair"); }), - ).pipe(Effect.provide(NodeServices.layer)), + ).pipe( + Effect.provide(NodeServices.layer), + Effect.provideService(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), + ), ); it.effect("pairs through the recorded dev web URL for dev servers", () => diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 73f5088ea..4a19c55dc 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -71,6 +73,77 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { }); it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect.each([ + { name: "missing", content: undefined }, + { name: "empty", content: "" }, + { name: "whitespace-only", content: " \t\n" }, + ])("concurrent initializers recover a $name environment id file", ({ content }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-concurrent-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + if (content !== undefined) { + yield* fileSystem.writeFileString(serverConfig.environmentIdPath, content); + } + const bothGenerated = yield* Deferred.make(); + const bothReadEmpty = yield* Deferred.make(); + const firstInitialized = yield* Deferred.make(); + let remaining = 2; + let emptyReads = 0; + const readIdentity = Effect.gen(function* () { + const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; + return yield* identity.getEnvironmentId; + }).pipe( + Effect.tap(() => Deferred.succeed(firstInitialized, undefined)), + Effect.provide(Layer.fresh(ServerEnvironment.identityLayer)), + Effect.provideService(ServerConfig.ServerConfig, serverConfig), + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readFileString: (path) => + fileSystem.readFileString(path).pipe( + Effect.tap( + Effect.fn(function* (value) { + if (path !== serverConfig.environmentIdPath || remaining > 0 || value.trim()) { + return; + } + // Both observe the empty file, but one repairs it after the other has finished. + if (++emptyReads === 2) { + yield* Deferred.succeed(bothReadEmpty, undefined); + yield* Deferred.await(firstInitialized); + } else { + yield* Deferred.await(bothReadEmpty); + } + }), + ), + ), + }), + Effect.provideService(Crypto.Crypto, { + ...crypto, + randomUUIDv4: Effect.gen(function* () { + const id = yield* crypto.randomUUIDv4; + if (--remaining === 0) { + yield* Deferred.succeed(bothGenerated, undefined); + } + yield* Deferred.await(bothGenerated); + return id; + }), + }), + ); + + const [first, second] = yield* Effect.all([readIdentity, readIdentity], { + concurrency: "unbounded", + }); + const persisted = yield* fileSystem.readFileString(serverConfig.environmentIdPath); + + expect(first).toBe(second); + expect(persisted.trim()).toBe(first); + }), + ); + it.effect("persists the environment id across service restarts", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -196,6 +269,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }); const serverConfig = yield* makeServerConfig(baseDir); const environmentIdPath = serverConfig.environmentIdPath; + const tempPath = `${environmentIdPath}.tmp`; const methodByOperation = { check: "exists", read: "readFileString", @@ -215,6 +289,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { exists: () => operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"), readFileString: () => Effect.fail(cause), + makeTempFileScoped: () => Effect.succeed(tempPath), writeFileString: (path) => { writeAttempts.push(path); return Effect.fail(cause); @@ -244,7 +319,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(error.message).toBe( `Server environment ID ${operation} failed at '${environmentIdPath}'.`, ); - expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []); + expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []); } }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index ddbad1531..53159affc 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -25,12 +25,15 @@ import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.t export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( "ServerEnvironmentIdPersistenceError", { - operation: Schema.Literals(["check", "read", "write"]), + operation: Schema.Literals(["check", "read", "write", "initialize"]), environmentIdPath: Schema.String, - cause: Schema.Defect(), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { + if (this.operation === "initialize") { + return `Server environment ID file is missing or empty after initialization at '${this.environmentIdPath}'.`; + } return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`; } } @@ -43,6 +46,13 @@ export class ServerEnvironment extends Context.Service< } >()("t3/environment/ServerEnvironment") {} +export class ServerEnvironmentIdentity extends Context.Service< + ServerEnvironmentIdentity, + { + readonly getEnvironmentId: Effect.Effect; + } +>()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {} + function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { switch (platform) { case "darwin": @@ -69,14 +79,10 @@ function platformArch( } } -export const make = Effect.gen(function* () { +const makeIdentity = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; - const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( @@ -108,17 +114,42 @@ export const make = Effect.gen(function* () { return raw.length > 0 ? raw : null; }); - const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe( - Effect.mapError( - (cause) => - new ServerEnvironmentIdPersistenceError({ - operation: "write", - environmentIdPath: serverConfig.environmentIdPath, - cause, - }), - ), - ); + const persistEnvironmentId = Effect.fn("ServerEnvironmentIdentity.persistEnvironmentId")( + function* (value: string, mode: "create" | "recover") { + const destinationPath = + mode === "recover" + ? `${serverConfig.environmentIdPath}.recovery` + : serverConfig.environmentIdPath; + const tempPath = yield* fileSystem.makeTempFileScoped({ + directory: serverConfig.stateDir, + prefix: ".environment-id-", + }); + yield* fileSystem.writeFileString(tempPath, `${value}\n`); + // Publish the completed file without replacing an ID created by another process. + yield* fileSystem + .link(tempPath, destinationPath) + .pipe( + Effect.catch((cause) => + cause.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(cause), + ), + ); + if (mode === "recover") { + // Keep the recovery ID so delayed initializers also publish the same winner. + yield* fileSystem.remove(tempPath); + yield* fileSystem.copyFile(destinationPath, tempPath); + yield* fileSystem.rename(tempPath, serverConfig.environmentIdPath); + } + }, + Effect.scoped, + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "write", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); const environmentIdRaw = yield* Effect.gen(function* () { const persisted = yield* readPersistedEnvironmentId; @@ -127,11 +158,35 @@ export const make = Effect.gen(function* () { } const generated = yield* crypto.randomUUIDv4; - yield* persistEnvironmentId(generated); - return generated; + yield* persistEnvironmentId(generated, "create"); + let winner = yield* readPersistedEnvironmentId; + if (winner === null) { + yield* persistEnvironmentId(generated, "recover"); + winner = yield* readPersistedEnvironmentId; + } + if (winner === null) { + return yield* new ServerEnvironmentIdPersistenceError({ + operation: "initialize", + environmentIdPath: serverConfig.environmentIdPath, + }); + } + return winner; }); const environmentId = EnvironmentId.make(environmentIdRaw); + return ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(environmentId), + }); +}); + +export const make = Effect.gen(function* () { + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const identity = yield* ServerEnvironmentIdentity; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); const machine = yield* detectServerEnvironmentMachineKind(); @@ -203,10 +258,15 @@ export const make = Effect.gen(function* () { }); }); +export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentity); + /** * ServerEnvironment is acquired from persisted filesystem and host-process * state. It intentionally has no fallback Layer.succeed value: callers must * provide the external platform services, a ServerConfig, and the * ServerSecretStore backing the descriptor's publishing capability. */ -export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); +export const layer = Layer.effect(ServerEnvironment, make).pipe( + Layer.provideMerge(identityLayer), + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7abbc68b3..36e014c3f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -348,6 +348,11 @@ const makeAuthTestLayer = () => EnvironmentAuth.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + }), + ), ); const makeBrowserOtlpPayload = (spanName: string) => @@ -2246,6 +2251,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("migrates a valid legacy remote-web session cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const currentCookie = cookie?.split(";")[0] ?? ""; + const legacyCookie = currentCookie.replace(/^t3_session_[^=]+=/, "t3_session="); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: { cookie: legacyCookie }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.equal(response.headers["set-cookie"], cookie); + assert.equal(response.headers["cache-control"], "no-store"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect.each(["cookie", "bearer"])( + "does not migrate a stale legacy cookie when %s auth succeeds", + (source) => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const sessionCookie = cookie?.split(";")[0] ?? ""; + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: + source === "cookie" + ? { cookie: `${sessionCookie}; t3_session=stale` } + : { authorization: `Bearer ${sessionToken}`, cookie: "t3_session=stale" }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.isUndefined(response.headers["set-cookie"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2cc5e93f4..bd91fb89f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -426,8 +426,13 @@ const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( Layer.provide(T3ProjectFileLoader.layer), ); +const ServerEnvironmentLayerLive = ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), +); + const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), + Layer.provide(ServerEnvironmentLayerLive), Layer.provide(ServerSecretStore.layer), ); @@ -502,7 +507,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)), Layer.provideMerge(RepositoryIdentityResolver.layer), - Layer.provideMerge(ServerEnvironment.layer), + Layer.provideMerge(ServerEnvironmentLayerLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 51cabcddc..7032f3fbf 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -41,6 +41,10 @@ It is identified by a stable `environmentId`, persisted by the server at `/environment-id.recovery` file so concurrent and delayed repairs choose +the same ID. Existing nonempty ID files remain authoritative. + ### Known environments and connection targets A saved client-side entry for an environment the client knows how to reach. It is not From 00445d0f5ddc2fb788abe948fe17faa9f3e9ae86 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 11 Sep 2026 01:40:09 -0600 Subject: [PATCH 04/10] refactor(web): share work-log grouping with the client runtime The web timeline kept its own copy of the tool-group action, summary and lifecycle-marker helpers, which had drifted from the shared client-runtime versions mobile uses: approval activity, viewed-image entries and case-varied "Read file" titles grouped differently on web. Web now imports the shared helpers, matching upstream. The rest of this source (viewed-image asset resolution, the web asset image component, and mobile feed rendering) already landed with the #9023 media preview port. Adopted from ce71c04f0aa9d2e5cd340e2a04cb1b0d5e24419d (#8936) --- .../components/chat/MessagesTimeline.logic.ts | 197 ++---------------- 1 file changed, 13 insertions(+), 184 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index e48d9f3f6..ab8436f50 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2,7 +2,19 @@ import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; -import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation"; +import { + normalizeCompactToolLabel, + omitSupersededLifecycleMarkers, + resolveWorkEntryToolPresentation, + summarizeToolGroup, + toolGroupAction, + toolGroupSummaryKind, + type ToolGroupSummaryKind, +} from "@t3tools/client-runtime/work-log/presentation"; +export { + normalizeCompactToolLabel, + toolGroupAction, +} from "@t3tools/client-runtime/work-log/presentation"; import { formatDuration, isStreamingMessageTextUpdate, @@ -406,189 +418,6 @@ export function computeMessageDurationStart( return result; } -export function normalizeCompactToolLabel(value: string): string { - return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); -} - -type ToolGroupAction = - | "browser" - | "read" - | "edit" - | "command" - | "code-search" - | "search" - | "other" - | "update"; -type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed"; - -function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean { - return ( - entry.itemType === "web_search" && - /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) - ); -} - -export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { - if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser"; - if ( - entry.requestKind === "file-read" || - entry.itemType === "image_view" || - (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File") - ) { - return "read"; - } - if ( - entry.requestKind === "file-change" || - entry.itemType === "file_change" || - (entry.changedFiles?.length ?? 0) > 0 - ) { - return "edit"; - } - if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) { - return "command"; - } - if (workLogEntryIsLocalCodeSearch(entry)) return "code-search"; - if (entry.itemType === "web_search") return "search"; - return workLogEntryIsToolLike(entry) ? "other" : "update"; -} - -function toolGroupActionCount( - action: ToolGroupAction, - entries: ReadonlyArray, -): number { - if (action !== "edit") return entries.length; - - const changedFiles = new Set(); - let editsWithoutFileDetails = 0; - for (const entry of entries) { - if (!entry.changedFiles || entry.changedFiles.length === 0) { - editsWithoutFileDetails += 1; - continue; - } - for (const file of entry.changedFiles) changedFiles.add(file); - } - return changedFiles.size + editsWithoutFileDetails; -} - -function toolGroupActionLabel(action: ToolGroupAction, count: number): string { - switch (action) { - case "read": - return `Read ${count} ${count === 1 ? "file" : "files"}`; - case "edit": - return `Changed ${count} ${count === 1 ? "file" : "files"}`; - case "command": - return `Ran ${count} ${count === 1 ? "command" : "commands"}`; - case "browser": - return `Used browser ${count} ${count === 1 ? "time" : "times"}`; - case "search": - return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; - case "code-search": - return `Searched code ${count} ${count === 1 ? "time" : "times"}`; - case "other": - return `Used ${count} ${count === 1 ? "tool" : "tools"}`; - case "update": - return `Received ${count} ${count === 1 ? "update" : "updates"}`; - } -} - -/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */ -function summarizeToolGroup(entries: ReadonlyArray): string { - const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry); - // A named source stands in for every call it made, so a run of Chrome steps - // reads as the integration rather than a tool count. - const sources = new Map>(); - const groupedEntries = new Map(); - for (const entry of summaryEntries) { - if (entry.toolSource) { - sources.set(entry.toolSource.key, entry.toolSource); - continue; - } - const action = toolGroupAction(entry); - const group = groupedEntries.get(action); - if (group) group.push(entry); - else groupedEntries.set(action, [entry]); - } - const labels = [...groupedEntries].map(([action, actionEntries]) => - toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), - ); - if (sources.size > 0) { - const sourceValues = [...sources.values()]; - const sourceNames = sourceValues.map((source) => source.name); - const formattedNames = - sourceNames.length < 2 - ? sourceNames[0]! - : sourceNames.length === 2 - ? sourceNames.join(" and ") - : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`; - const allIntegrations = sourceValues.every((source) => source.kind === "integration"); - labels.unshift( - `Used ${formattedNames}${allIntegrations ? ` ${sources.size === 1 ? "integration" : "integrations"}` : ""}`, - ); - } - const sentenceLabels = labels.map((label, index) => - index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), - ); - if (sentenceLabels.length < 2) return sentenceLabels[0] ?? ""; - if (sentenceLabels.length === 2) return sentenceLabels.join(" and "); - return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; -} - -function omitSupersededLifecycleMarkers( - entries: readonly T[], - workEntryFor: (entry: T) => WorkLogEntry, -): T[] { - const laterTerminalIdentities = new Set(); - const reversedEntries: T[] = []; - - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]!; - const workEntry = workEntryFor(entry); - const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label); - const identity = [ - workEntry.turnId ?? "no-turn", - workEntry.itemType ?? "", - normalizedLabel, - ].join("\u001f"); - const isStatuslessIdlessMarker = - workEntry.toolCallId === undefined && - workEntry.toolLifecycleStatus === undefined && - (workEntry.sourceActivityKind === "tool.started" || - workEntry.sourceActivityKind === "tool.updated"); - if (isStatuslessIdlessMarker && laterTerminalIdentities.has(identity)) continue; - - reversedEntries.push(entry); - if ( - workEntry.sourceActivityKind === "tool.completed" || - (workEntry.toolLifecycleStatus !== undefined && - workEntry.toolLifecycleStatus !== "inProgress") - ) { - laterTerminalIdentities.add(identity); - } - } - - return reversedEntries.toReversed(); -} - -function toolGroupSummaryKind(entries: ReadonlyArray): ToolGroupSummaryKind { - const actions = new Set(entries.map(toolGroupAction)); - if (actions.size !== 1) return "mixed"; - - const action = actions.values().next().value!; - if (action !== "other") return action; - - const fallbackKinds = new Set( - entries.map((entry): ToolGroupSummaryKind => { - if (entry.itemType === "mcp_tool_call") return "other"; - if (entry.itemType === "dynamic_tool_call") return "dynamic-tool"; - if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent-tool"; - if (entry.tone === "thinking") return "agent-tool"; - if (entry.tone === "tool") return "tone-tool"; - return "other"; - }), - ); - return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; -} - function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` From 8ab426a4870359241eaa49fffa979c55170dcf04 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 11 Sep 2026 01:44:33 -0600 Subject: [PATCH 05/10] perf(client-runtime): halve server config bootstrap traffic Each client session requested the full server config twice while connecting: once through `server.getConfig` for bootstrap and again through the config subscription. The session now opens one `subscribeServerConfig` stream, takes its first snapshot as the initial config, and replays that snapshot plus the latest theme and usage-limit source events to the shared config state, so later subscribers never open a duplicate stream. Web opts into environment themes, usage-limit sources and the `/usage-limits` command; mobile opts into usage-limit sources and the command, matching the options each client's config state already subscribes with. A different input still opens its own subscription. If the owned stream fails, dies or ends, the connection supervisor recovers the session instead of serving stale config. The config projection moves to its own module. Servers without a connection probe still fall back to `server.getConfig` for probes, and every server Pylon supports sends a snapshot first. Adopted from b883fc066ea5c9bebbe1c3e9b4bc2471aab3685f (#8367) --- apps/mobile/src/connection/runtime.ts | 4 +- apps/web/src/cloud/linkEnvironment.test.ts | 1 + apps/web/src/connection/runtime.ts | 8 +- .../client-runtime/src/connection/layer.ts | 57 +- .../src/connection/registry.test.ts | 2 + .../src/connection/supervisor.test.ts | 1 + .../src/operations/commands.test.ts | 1 + .../client-runtime/src/rpc/client.test.ts | 36 + packages/client-runtime/src/rpc/client.ts | 6 +- .../client-runtime/src/rpc/session.test.ts | 819 +++++++++++++++++- packages/client-runtime/src/rpc/session.ts | 235 ++++- .../src/state/pullRequests.test.ts | 1 + .../client-runtime/src/state/server.test.ts | 3 +- packages/client-runtime/src/state/server.ts | 115 +-- .../src/state/serverConfigProjection.ts | 99 +++ .../src/state/serverUsage.test.ts | 1 + .../src/state/shell-sync.test.ts | 1 + .../src/state/sourceControl.test.ts | 1 + .../src/state/threads-atoms.test.ts | 1 + .../src/state/threads-failures.test.ts | 1 + .../src/state/threads-pagination.test.ts | 1 + .../src/state/threads-sync.test.ts | 1 + packages/client-runtime/src/state/vcs.test.ts | 1 + .../src/state/vcsAction.test.ts | 1 + 24 files changed, 1221 insertions(+), 176 deletions(-) create mode 100644 packages/client-runtime/src/state/serverConfigProjection.ts diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index c5aebcd87..ce478962b 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -31,7 +31,9 @@ type ConnectionLayerSource = | typeof mobileBackgroundActivityReporterLayer; const providedClientConnectionLayer = snapshotLoaderLayer.pipe( - Layer.provideMerge(Connection.layer), + Layer.provideMerge( + Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }), + ), Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 1730030d6..3ae0dbd74 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -88,6 +88,7 @@ function registryLayer(options?: { const session: RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index f9a4ff757..b5e78287f 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -31,7 +31,13 @@ type ConnectionLayerSource = | typeof backgroundActivityReporterLayer; const providedClientConnectionLayer = snapshotLoaderLayer.pipe( - Layer.provideMerge(Connection.layer), + Layer.provideMerge( + Connection.layerWithOptions({ + environmentThemes: true, + usageLimitSources: true, + usageLimitsCommand: true, + }), + ), Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index c49f95a8c..43153838d 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -11,33 +11,32 @@ import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as RpcSession from "../rpc/session.ts"; -const driverLayer = ConnectionDriver.layer.pipe( - Layer.provide(Layer.mergeAll(ConnectionResolver.layer, RpcSession.layer)), -); +export function layerWithOptions(options: RpcSession.RpcSessionOptions) { + const driverLayer = ConnectionDriver.layer.pipe( + Layer.provide(Layer.mergeAll(ConnectionResolver.layer, RpcSession.layerWithOptions(options))), + ); + const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); + const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); + const connectionServicesLayer = Layer.mergeAll( + registryLayer, + RelayEnvironmentDiscovery.layer, + onboardingLayer, + ); + const connectionStartupLayer = Layer.effectDiscard( + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; + yield* registry.start; + yield* platformSource.registrations.pipe( + Stream.runForEach(registry.reconcilePlatform), + Effect.forkScoped, + ); + }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), + ); + return connectionStartupLayer.pipe( + Layer.provideMerge(connectionServicesLayer), + Layer.provideMerge(RemoteEnvironmentAuthorization.layer), + ); +} -const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); - -const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); - -const connectionServicesLayer = Layer.mergeAll( - registryLayer, - RelayEnvironmentDiscovery.layer, - onboardingLayer, -); - -const connectionStartupLayer = Layer.effectDiscard( - Effect.gen(function* () { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; - yield* registry.start; - yield* platformSource.registrations.pipe( - Stream.runForEach(registry.reconcilePlatform), - Effect.forkScoped, - ); - }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), -); - -export const layer = connectionStartupLayer.pipe( - Layer.provideMerge(connectionServicesLayer), - Layer.provideMerge(RemoteEnvironmentAuthorization.layer), -); +export const layer = layerWithOptions({}); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 258927fc0..d756eeaf1 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -357,6 +357,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( Effect.succeed({ client: {} as RpcSession.RpcSession["client"], initialConfig: Effect.die(new Error("Config is not used by registry tests.")), + subscribeServerConfig: () => + Stream.die(new Error("Config is not used by registry tests.")), ready: Effect.void, probe: Effect.void, closed: Deferred.await(closed), diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 53ad4bd20..2da7a68bd 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -174,6 +174,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: Effect.succeed({ client: TEST_RPC_CLIENT, initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), + subscribeServerConfig: (input) => TEST_RPC_CLIENT.subscribeServerConfig(input), ready: options?.ready?.(attempt) ?? Effect.void, probe: options?.probe?.(attempt) ?? Effect.void, closed: Deferred.await(closed), diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 6d87a6e85..5cc175864 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -58,6 +58,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct const session: RpcSession.RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 7f7944c4a..467291390 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -1,9 +1,11 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, ThreadId, type ServerConfig, type PreviewSessionSnapshot, type RelayClientInstallProgressEvent, + type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, WS_METHODS, } from "@t3tools/contracts"; @@ -59,6 +61,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -89,6 +92,39 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect("reuses the session config stream instead of opening a duplicate subscription", () => + Effect.gen(function* () { + const event: ServerConfigStreamEvent = { + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + }; + let duplicateSubscriptions = 0; + const client = { + [WS_METHODS.subscribeServerConfig]: () => { + duplicateSubscriptions += 1; + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set( + activeSession, + Option.some({ + ...session(client), + subscribeServerConfig: () => Stream.succeed(event), + }), + ); + + const received = yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe( + Stream.runHead, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(received).toEqual(Option.some(event)); + expect(duplicateSubscriptions).toBe(0); + }), + ); + for (const [profileId, supported, allowed] of [ [undefined, undefined, true], ["default", undefined, true], diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index ca4644dc1..6e529a135 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -239,7 +239,11 @@ function subscribeDynamicMapped( Option.match({ onNone: () => Stream.empty, onSome: (session) => { - const method = session.client[tag] as ( + const method = ( + tag === WS_METHODS.subscribeServerConfig + ? session.subscribeServerConfig + : session.client[tag] + ) as ( input: EnvironmentRpcInput, ) => Stream.Stream< EnvironmentRpcStreamValue, diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index caeae18d4..cd843c1f7 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -1,25 +1,43 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, ServerConfig, type ServerConfig as ServerConfigType, + ServerConfigStreamEvent, + type ServerConfigStreamEvent as ServerConfigStreamEventType, WS_METHODS, + UsageLimitSourceId, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as TestClock from "effect/testing/TestClock"; import * as Socket from "effect/unstable/socket/Socket"; import { + AVAILABLE_CONNECTION_STATE, + ConnectionBlockedError, ConnectionTransientError, PrimaryConnectionTarget, RelayConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "./session.ts"; +import { makeEnvironmentServerConfigState } from "../state/server.ts"; +import { applyServerConfigProjection } from "../state/serverConfigProjection.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; type SocketEventType = "open" | "message" | "close" | "error"; @@ -141,10 +159,47 @@ const RpcRequest = Schema.TaggedStruct("Request", { tag: Schema.String, }); const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); +const isRpcRequest = Schema.is(RpcRequest); +const isPing = Schema.is(Schema.Struct({ _tag: Schema.Literal("Ping") })); const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encodeServerConfig = Schema.encodeSync(ServerConfig); +const encodeServerConfigStreamEvent = Schema.encodeSync(ServerConfigStreamEvent); +const encodeDefect = Schema.encodeSync(Schema.Defect()); const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG); +const THEME_SERVER_CONFIG: ServerConfigType = { + ...SERVER_CONFIG, + environment: { + ...SERVER_CONFIG.environment, + capabilities: { + ...SERVER_CONFIG.environment.capabilities, + environmentThemes: true, + }, + }, +}; +const ENCODED_THEME_SERVER_CONFIG = encodeServerConfig(THEME_SERVER_CONFIG); +const SOURCE_SERVER_CONFIG: ServerConfigType = { + ...THEME_SERVER_CONFIG, + environment: { + ...THEME_SERVER_CONFIG.environment, + capabilities: { ...THEME_SERVER_CONFIG.environment.capabilities, usageLimitSources: true }, + }, +}; +const SOURCE_EVENT: ServerConfigStreamEventType = { + version: 1, + type: "usageLimitSourcesUpdated", + payload: { + sources: [ + { + id: UsageLimitSourceId.make("proxy"), + kind: "cliproxy", + label: "Proxy", + checkedAt: "2026-09-04T00:00:00Z", + accounts: [], + }, + ], + }, +}; + const LEGACY_SERVER_CONFIG = { ...ENCODED_SERVER_CONFIG, environment: { @@ -155,23 +210,26 @@ const LEGACY_SERVER_CONFIG = { }, }; -const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { +const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* ( + options: RpcSession.RpcSessionOptions = {}, +) { const sockets: TestWebSocket[] = []; const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { const socket = new TestWebSocket(url); sockets.push(socket); return socket as unknown as globalThis.WebSocket; }); - const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); + const layer = RpcSession.layerWithOptions(options).pipe(Layer.provide(constructorLayer)); const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); return { factory, sockets }; }); const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( sockets: ReadonlyArray, + index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const socket = sockets[0]; + const socket = sockets[index]; if (socket) { return socket; } @@ -185,9 +243,9 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const request = socket.sent[index]; + const request = socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)[index]; if (request) { - return decodeRpcRequest(decodeJson(request)); + return request; } yield* Effect.yieldNow; } @@ -197,21 +255,33 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( socket: TestWebSocket, config: unknown = ENCODED_SERVER_CONFIG, + payload: unknown = {}, ) { const request = yield* awaitRequest(socket); expect(request).toMatchObject({ _tag: "Request", - tag: WS_METHODS.serverGetConfig, - payload: {}, + tag: WS_METHODS.subscribeServerConfig, + payload, }); socket.serverMessage( encodeJson({ - _tag: "Exit", + _tag: "Chunk", requestId: request.id, - exit: { - _tag: "Success", - value: config, - }, + values: [{ version: 1, type: "snapshot", config }], + }), + ); +}); + +const publishConfigEvents = Effect.fn("TestRpcSessionFactory.publishConfigEvents")(function* ( + socket: TestWebSocket, + events: ReadonlyArray, +) { + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: events.map((event) => encodeServerConfigStreamEvent(event)), }), ); }); @@ -231,7 +301,9 @@ describe("RpcSessionFactory", () => { const config = yield* session.initialConfig; expect(config).toEqual(SERVER_CONFIG); - expect(socket.sent).toHaveLength(1); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); const probeFiber = yield* Effect.forkChild(session.probe); const probeRequest = yield* awaitRequest(socket, 1); @@ -252,19 +324,25 @@ describe("RpcSessionFactory", () => { ); yield* Fiber.join(probeFiber); - expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ - WS_METHODS.serverGetConfig, - WS_METHODS.serverProbe, - ]); + expect( + socket.sent + .map((message) => decodeJson(message)) + .filter(isRpcRequest) + .map((request) => request.tag), + ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverProbe]); socket.close(1012, "service restart"); const error = yield* Effect.flip(session.closed); + const configStreamError = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runDrain, Effect.flip); expect(error).toBeInstanceOf(ConnectionTransientError); expect(error).toMatchObject({ reason: "transport", message: "Test environment disconnected.", }); + expect(configStreamError).toMatchObject({ _tag: "RpcClientError" }); yield* Effect.yieldNow; expect(sockets).toHaveLength(1); }), @@ -289,6 +367,699 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("replays current config and broadcasts updates to every subscriber", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const collectTwo = session + .subscribeServerConfig({}) + .pipe(Stream.take(2), Stream.runCollect); + const firstSubscriber = yield* Effect.forkChild(collectTwo); + const secondSubscriber = yield* Effect.forkChild(collectTwo); + yield* Effect.yieldNow; + + const shortcut = { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }; + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [ + { + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [{ command: "terminal.toggle", shortcut }], + issues: [], + }, + }, + ], + }), + ); + + const firstEvents = Array.from(yield* Fiber.join(firstSubscriber)); + const secondEvents = Array.from(yield* Fiber.join(secondSubscriber)); + expect(firstEvents.map((event) => event.type)).toEqual(["snapshot", "keybindingsUpdated"]); + expect(secondEvents).toEqual(firstEvents); + + const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); + expect(replay).toMatchObject({ + _tag: "Some", + value: { + type: "snapshot", + config: { keybindings: [{ command: "terminal.toggle", shortcut }] }, + }, + }); + }), + ), + ); + + for (const options of [ + { environmentThemes: true }, + { usageLimitSources: true }, + { environmentThemes: true, usageLimitSources: true }, + ]) { + it.effect( + `shares only a config subscription with the same opt-ins: ${JSON.stringify(options)}`, + () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(options); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options); + yield* Fiber.join(readyFiber); + + const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead); + expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); + expect( + socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest), + ).toHaveLength(1); + + const fallbackFiber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runHead, Effect.forkChild); + const fallbackRequest = yield* awaitRequest(socket, 1); + expect(fallbackRequest).toMatchObject({ + tag: WS_METHODS.subscribeServerConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: fallbackRequest.id, + values: [ + { + version: 1, + type: "snapshot", + config: ENCODED_THEME_SERVER_CONFIG, + }, + ], + }), + ); + expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ + _tag: "Some", + value: { type: "snapshot" }, + }); + }), + ), + ); + } + + it.effect.each([ + { usageLimitSources: true }, + { environmentThemes: true, usageLimitSources: true }, + ])("replays usage sources, removal, and capability downgrade with %j", (options) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(options); + const session = yield* factory.connect(PREPARED); + const ready = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, encodeServerConfig(SOURCE_SERVER_CONFIG), options); + yield* Fiber.join(ready); + const observed = yield* Queue.unbounded(); + yield* session.subscribeServerConfig(options).pipe( + Stream.runForEach((event) => Queue.offer(observed, event)), + Effect.forkChild, + ); + expect((yield* Queue.take(observed)).type).toBe("snapshot"); + const themes: ServerConfigStreamEventType[] = options.environmentThemes + ? [{ version: 1, type: "environmentThemesUpdated", payload: { themes: [] } }] + : []; + for (const event of themes) { + yield* publishConfigEvents(socket, [event]); + expect(yield* Queue.take(observed)).toEqual(event); + } + const events: ServerConfigStreamEventType[] = [ + SOURCE_EVENT, + { version: 1, type: "usageLimitSourcesUpdated", payload: { sources: [] } }, + SOURCE_EVENT, + { version: 1, type: "snapshot", config: THEME_SERVER_CONFIG }, + ]; + for (const event of events) { + yield* publishConfigEvents(socket, [event]); + expect(yield* Queue.take(observed)).toEqual(event); + const started = yield* Deferred.make(); + const replay = yield* session.subscribeServerConfig(options).pipe( + Stream.tap(() => Deferred.succeed(started, undefined)), + Stream.takeUntil((item) => item.type === "keybindingsUpdated"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(started); + // A live end marker makes a missing or stale replay event fail without a timeout. + const marker: ServerConfigStreamEventType = { + version: 1, + type: "keybindingsUpdated", + payload: { keybindings: [], issues: [] }, + }; + yield* publishConfigEvents(socket, [marker]); + expect(yield* Queue.take(observed)).toEqual(marker); + const replayed = Array.from(yield* Fiber.join(replay)); + expect(replayed.slice(1)).toEqual([ + ...themes, + ...(event.type === "snapshot" ? [] : [event]), + marker, + ]); + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: SOURCE_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, SOURCE_EVENT); + for (const item of replayed) projection = applyServerConfigProjection(projection, item); + expect(Option.getOrThrow(projection).config.usageLimitSources).toEqual( + event.type === "usageLimitSourcesUpdated" && event.payload.sources.length > 0 + ? event.payload.sources + : undefined, + ); + } + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); + }), + ), + ); + + it.effect("replays theme updates and deletion as authoritative events", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const firstThemes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ]; + const replacementThemes = [ + { + id: "midnight", + name: "Midnight", + appearance: "dark" as const, + canvas: "#000000", + accent: "#ffffff", + }, + ]; + const subscriberStarted = yield* Deferred.make(); + const subscriber = yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( + Stream.mapEffect((event) => + Deferred.succeed(subscriberStarted, undefined).pipe(Effect.as(event)), + ), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(subscriberStarted); + yield* publishConfigEvents(socket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: replacementThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }, + ]); + + const liveEvents = Array.from(yield* Fiber.join(subscriber)); + expect(liveEvents.map((event) => event.type)).toEqual([ + "snapshot", + "environmentThemesUpdated", + "environmentThemesUpdated", + "environmentThemesUpdated", + ]); + expect(liveEvents[2]).toMatchObject({ payload: { themes: replacementThemes } }); + + const replay = Array.from( + yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.take(2), Stream.runCollect), + ); + expect(replay.map((event) => event.type)).toEqual(["snapshot", "environmentThemesUpdated"]); + expect(replay[1]).toMatchObject({ payload: { themes: [] } }); + + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: THEME_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }); + for (const event of replay) { + projection = applyServerConfigProjection(projection, event); + } + expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); + }), + ), + ); + + it.effect("recovers a slow subscriber after it misses theme and usage-source deletion", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ + environmentThemes: true, + usageLimitSources: true, + }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, encodeServerConfig(SOURCE_SERVER_CONFIG), { + environmentThemes: true, + usageLimitSources: true, + }); + yield* Fiber.join(readyFiber); + + const slowSubscriberStarted = yield* Deferred.make(); + const releaseSlowSubscriber = yield* Deferred.make(); + let firstEvent = true; + const slowSubscriber = yield* session + .subscribeServerConfig({ environmentThemes: true, usageLimitSources: true }) + .pipe( + Stream.mapEffect((event) => { + if (!firstEvent) return Effect.succeed(event); + firstEvent = false; + return Deferred.succeed(slowSubscriberStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSlowSubscriber)), + Effect.as(event), + ); + }), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(slowSubscriberStarted); + + const firstThemes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ]; + const themeEvents: ServerConfigStreamEventType[] = [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { + themes: [{ ...firstThemes[0]!, name: "Nightfall 2" }], + }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }, + ]; + const settingsEvents = Array.from( + { length: 65 }, + (): ServerConfigStreamEventType => ({ + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + }), + ); + const sourceEvents: ServerConfigStreamEventType[] = [ + SOURCE_EVENT, + { version: 1, type: "usageLimitSourcesUpdated", payload: { sources: [] } }, + ]; + const allEvents = [...themeEvents, ...sourceEvents, ...settingsEvents]; + const observedByFastSubscriber = yield* Queue.unbounded(); + yield* session + .subscribeServerConfig({ environmentThemes: true, usageLimitSources: true }) + .pipe( + Stream.runForEach((event) => Queue.offer(observedByFastSubscriber, event)), + Effect.forkChild, + ); + expect((yield* Queue.take(observedByFastSubscriber)).type).toBe("snapshot"); + for (const event of allEvents) { + yield* publishConfigEvents(socket, [event]); + expect(yield* Queue.take(observedByFastSubscriber)).toEqual(event); + } + yield* Deferred.succeed(releaseSlowSubscriber, undefined); + + const recovered = Array.from(yield* Fiber.join(slowSubscriber)); + expect(recovered.map((event) => event.type)).toEqual([ + "snapshot", + "snapshot", + "environmentThemesUpdated", + "usageLimitSourcesUpdated", + ]); + expect(recovered[2]).toMatchObject({ payload: { themes: [] } }); + expect(recovered[3]).toMatchObject({ payload: { sources: [] } }); + + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: SOURCE_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, themeEvents[0]!); + projection = applyServerConfigProjection(projection, SOURCE_EVENT); + for (const event of recovered.slice(1)) { + projection = applyServerConfigProjection(projection, event); + } + expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); + expect(Option.getOrThrow(projection).config.usageLimitSources).toBeUndefined(); + }), + ), + ); + + it.effect("closes the session when the config source dies", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const closedFiber = yield* session.closed.pipe(Effect.exit, Effect.forkChild); + socket.serverMessage( + encodeJson({ + _tag: "Defect", + defect: encodeDefect(new Error("config stream died")), + }), + ); + + const closed = yield* Fiber.join(closedFiber); + expect(Exit.isFailure(closed)).toBe(true); + if (Exit.isFailure(closed)) { + expect(Cause.hasDies(closed.cause)).toBe(true); + } + }), + ), + ); + + it.effect.each([{ failure: "defect" as const }, { failure: "typed" as const }])( + "keeps durable config state alive after an owned $failure failure", + ({ failure }) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const firstSession = yield* factory.connect(PREPARED); + const firstReady = yield* Effect.forkChild(firstSession.ready); + const firstSocket = yield* awaitSocket(sockets); + firstSocket.open(); + yield* completeInitialConfig(firstSocket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(firstReady); + + const activeSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const configState = yield* makeEnvironmentServerConfigState({ + environmentThemes: true, + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + const awaitConfig = (predicate: (config: ServerConfigType) => boolean) => + SubscriptionRef.changes(configState).pipe( + Stream.filter(Option.isSome), + Stream.map((projection) => projection.value.config), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + + const firstThemes = [ + { + id: "first-theme", + name: "First theme", + appearance: "dark" as const, + canvas: "#111111", + accent: "#ffffff", + }, + ]; + const firstThemeState = yield* awaitConfig( + (config) => config.environmentThemes?.[0]?.id === "first-theme", + ).pipe(Effect.forkChild); + yield* publishConfigEvents(firstSocket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + ]); + expect((yield* Fiber.join(firstThemeState)).environmentThemes).toEqual(firstThemes); + + const firstClosed = yield* firstSession.closed.pipe(Effect.exit, Effect.forkChild); + const firstRequest = yield* awaitRequest(firstSocket); + firstSocket.serverMessage( + failure === "defect" + ? encodeJson({ + _tag: "Defect", + defect: encodeDefect(new Error("config stream died")), + }) + : encodeJson({ + _tag: "Exit", + requestId: firstRequest.id, + exit: { + _tag: "Failure", + cause: [ + { + _tag: "Fail", + error: { + _tag: "EnvironmentAuthorizationError", + message: "config subscription rejected", + requiredScope: "orchestration:read", + }, + }, + ], + }, + }), + ); + const firstClosedExit = yield* Fiber.join(firstClosed); + expect(Exit.isFailure(firstClosedExit)).toBe(true); + if (failure === "typed" && Exit.isFailure(firstClosedExit)) { + expect(Cause.squash(firstClosedExit.cause)).toBeInstanceOf(ConnectionBlockedError); + expect(Cause.squash(firstClosedExit.cause)).toMatchObject({ reason: "permission" }); + } + yield* SubscriptionRef.set(activeSession, Option.none()); + + const recoveredConfig = { + ...THEME_SERVER_CONFIG, + environment: { + ...THEME_SERVER_CONFIG.environment, + label: "Recovered environment", + }, + } satisfies ServerConfigType; + const secondSession = yield* factory.connect(PREPARED); + const secondReady = yield* Effect.forkChild(secondSession.ready); + const secondSocket = yield* awaitSocket(sockets, 1); + secondSocket.open(); + yield* completeInitialConfig(secondSocket, encodeServerConfig(recoveredConfig), { + environmentThemes: true, + }); + yield* Fiber.join(secondReady); + + const recoveredState = yield* awaitConfig( + (config) => config.environment.label === "Recovered environment", + ).pipe(Effect.forkChild); + yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); + expect((yield* Fiber.join(recoveredState)).environmentThemes).toEqual(firstThemes); + + const recoveredThemes = [ + { + id: "recovered-theme", + name: "Recovered theme", + appearance: "dark" as const, + canvas: "#000000", + accent: "#eeeeee", + }, + ]; + const liveRecoveredState = yield* awaitConfig( + (config) => config.environmentThemes?.[0]?.id === "recovered-theme", + ).pipe(Effect.forkChild); + yield* publishConfigEvents(secondSocket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: recoveredThemes }, + }, + ]); + expect((yield* Fiber.join(liveRecoveredState)).environmentThemes).toEqual( + recoveredThemes, + ); + }), + ), + ); + + it.effect.each<{ + readonly event: ServerConfigStreamEventType; + readonly expectedConfig: Partial; + }>([ + { + event: { + version: 1, + type: "providerStatuses", + payload: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + }, + }, + expectedConfig: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + }, + }, + { + event: { + version: 1, + type: "settingsUpdated", + payload: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, + }, + }, + }, + expectedConfig: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, + }, + }, + }, + ])( + "preserves $event.type events and includes them in replay snapshots", + ({ event, expectedConfig }) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const subscriber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkChild); + yield* Effect.yieldNow; + + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [encodeServerConfigStreamEvent(event)], + }), + ); + + const events = Array.from(yield* Fiber.join(subscriber)); + expect(events[1]).toEqual(event); + + const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); + expect(replay).toMatchObject({ + _tag: "Some", + value: { + type: "snapshot", + config: expectedConfig, + }, + }); + }), + ), + ); + it.effect("tolerates two missed pong windows before closing the session", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); @@ -303,7 +1074,7 @@ describe("RpcSessionFactory", () => { yield* TestClock.adjust("15 seconds"); expect(closedFiber.pollUnsafe()).toBeUndefined(); - expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([ + expect(socket.sent.map((message) => decodeJson(message)).filter(isPing)).toEqual([ { _tag: "Ping" }, { _tag: "Ping" }, { _tag: "Ping" }, @@ -381,10 +1152,12 @@ describe("RpcSessionFactory", () => { ); yield* Fiber.join(probeFiber); - expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ - WS_METHODS.serverGetConfig, - WS_METHODS.serverGetConfig, - ]); + expect( + socket.sent + .map((message) => decodeJson(message)) + .filter(isRpcRequest) + .map((request) => request.tag), + ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverGetConfig]); }), ), ); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 23be6d01d..8e1aabb21 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -1,12 +1,26 @@ -import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import { + type ServerConfig, + type ServerConfigStreamEvent, + WsSubscribeServerConfigRpc, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import type * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as Socket from "effect/unstable/socket/Socket"; @@ -21,15 +35,30 @@ import { ConnectionBlockedError, ConnectionTransientError as ConnectionTransientErrorClass, } from "../connection/model.ts"; +import { + applyServerConfigProjection, + type ServerConfigProjection, + withoutEnvironmentThemes, +} from "../state/serverConfigProjection.ts"; const SOCKET_OPEN_TIMEOUT = "15 seconds"; export interface RpcSession { readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; + readonly subscribeServerConfig: ( + input: ServerConfigSubscriptionInput, + ) => ServerConfigSubscription; readonly ready: Effect.Effect; readonly probe: Effect.Effect; - readonly closed: Effect.Effect; + readonly closed: Effect.Effect; +} + +export interface RpcSessionOptions { + readonly environmentThemes?: boolean; + readonly usageLimitSources?: boolean; + /** This client answers /usage-limits itself, so the server may advertise it. */ + readonly usageLimitsCommand?: boolean; } export class RpcSessionFactory extends Context.Service< @@ -45,11 +74,57 @@ type InitialConfigError = Effect.Error< ReturnType >; type ProbeError = Effect.Error>; +type ServerConfigSubscriptionError = + | Rpc.ErrorExit + | RpcClientError.RpcClientError; +type ServerConfigSubscription = Stream.Stream< + ServerConfigStreamEvent, + ServerConfigSubscriptionError +>; +type ServerConfigSubscriptionInput = Parameters< + WsRpcProtocolClient[typeof WS_METHODS.subscribeServerConfig] +>[0]; +type EnvironmentThemesUpdatedEvent = Extract< + ServerConfigStreamEvent, + { readonly type: "environmentThemesUpdated" } +>; +type UsageLimitSourcesUpdatedEvent = Extract< + ServerConfigStreamEvent, + { readonly type: "usageLimitSourcesUpdated" } +>; + +interface ServerConfigReplayState { + readonly projection: ServerConfigProjection; + readonly revision: number; + readonly themesEvent: EnvironmentThemesUpdatedEvent | undefined; + readonly sourcesEvent: UsageLimitSourcesUpdatedEvent | undefined; +} + +interface BufferedServerConfigEvent { + readonly event: ServerConfigStreamEvent; + readonly replay: ServerConfigReplayState; + readonly revision: number; +} + +function serverConfigReplayEvents( + state: ServerConfigReplayState, +): ReadonlyArray { + const snapshot = { + version: 1 as const, + type: "snapshot" as const, + config: withoutEnvironmentThemes(state.projection.config), + }; + return [ + snapshot, + ...(state.themesEvent === undefined ? [] : [state.themesEvent]), + ...(state.sourcesEvent === undefined ? [] : [state.sourcesEvent]), + ]; +} const isSocketErrorReason = Schema.is(Socket.SocketErrorReason); function mapSessionRpcError( - error: InitialConfigError | ProbeError, + error: InitialConfigError | ProbeError | ServerConfigSubscriptionError, networkHint: string, ): ConnectionAttemptError { switch (error._tag) { @@ -72,8 +147,13 @@ function mapSessionRpcError( } } -const make = Effect.gen(function* () { +const make = Effect.fn("RpcSessionFactory.make")(function* (options: RpcSessionOptions = {}) { const webSocketConstructor = yield* Socket.WebSocketConstructor; + const serverConfigInput: ServerConfigSubscriptionInput = { + ...(options.environmentThemes === true ? { environmentThemes: true } : {}), + ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), + }; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { const networkHint = @@ -126,18 +206,142 @@ const make = Effect.gen(function* () { const protocolContext = yield* Layer.build(protocolLayer).pipe( Effect.withSpan("environment.websocket.connect"), ); - const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); - const initialConfig = yield* Effect.cached( - client[WS_METHODS.serverGetConfig]({}).pipe( + const protocolClient = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const initialConfigDeferred = yield* Deferred.make(); + const serverConfigExit = yield* Deferred.make(); + const configSubscriptionClosed = yield* Deferred.make(); + const serverConfigState = yield* Ref.make(Option.none()); + const serverConfigUpdates = yield* PubSub.sliding(64); + const configSubscriptionEndedError = new ConnectionTransientErrorClass({ + reason: "remote-unavailable", + detail: `${connection.label} config subscription ended.`, + }); + const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]( + serverConfigInput, + ).pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + const buffered = yield* Ref.modify(serverConfigState, (current) => { + const projection = applyServerConfigProjection( + Option.map(current, (state) => state.projection), + event, + ); + if (Option.isNone(projection)) { + return [Option.none(), current] as const; + } + const next = { + projection: projection.value, + revision: Option.match(current, { + onNone: () => 1, + onSome: (state) => state.revision + 1, + }), + themesEvent: + event.type === "environmentThemesUpdated" + ? event + : event.type === "snapshot" && + event.config.environment.capabilities.environmentThemes !== true + ? undefined + : Option.getOrUndefined(current)?.themesEvent, + sourcesEvent: + event.type === "usageLimitSourcesUpdated" + ? event + : event.type === "snapshot" && + event.config.environment.capabilities.usageLimitSources !== true + ? undefined + : Option.getOrUndefined(current)?.sourcesEvent, + } satisfies ServerConfigReplayState; + return [ + Option.some({ event, replay: next, revision: next.revision }), + Option.some(next), + ] as const; + }); + if (Option.isSome(buffered)) { + yield* PubSub.publish(serverConfigUpdates, buffered.value); + } + if (event.type === "snapshot") { + yield* Deferred.succeed(initialConfigDeferred, event.config); + } + }), + ), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) { + return Effect.all([ + Deferred.succeed(serverConfigExit, undefined), + Deferred.fail(configSubscriptionClosed, configSubscriptionEndedError), + ]).pipe(Effect.asVoid); + } + if (Cause.hasInterruptsOnly(exit.cause)) { + return Effect.void; + } + return Effect.all([ + Deferred.failCause(serverConfigExit, exit.cause), + Deferred.failCause(configSubscriptionClosed, Cause.map(exit.cause, mapRpcError)), + ]).pipe(Effect.asVoid); + }), + ); + yield* serverConfigSource.pipe(Effect.forkScoped); + const initialConfig = Effect.raceFirst( + Deferred.await(initialConfigDeferred), + Deferred.await(serverConfigExit).pipe( Effect.mapError(mapRpcError), - Effect.withSpan("environment.initialSync"), + Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)), ), + ).pipe(Effect.withSpan("environment.initialSync")); + const serverConfigEvents = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(serverConfigUpdates); + yield* Effect.raceFirst( + Deferred.await(initialConfigDeferred).pipe(Effect.asVoid), + Deferred.await(serverConfigExit), + ); + const snapshot = yield* Ref.get(serverConfigState); + if (Option.isNone(snapshot)) { + return Stream.empty; + } + const updates = Stream.fromSubscription(subscription).pipe( + Stream.filter((buffered) => buffered.revision > snapshot.value.revision), + Stream.mapAccum( + () => snapshot.value.revision, + (revision, buffered) => [ + buffered.revision, + buffered.revision === revision + 1 + ? [buffered.event] + : serverConfigReplayEvents(buffered.replay), + ], + ), + ); + const terminal = Stream.fromEffect(Deferred.await(serverConfigExit)).pipe(Stream.drain); + return Stream.concat( + Stream.fromIterable(serverConfigReplayEvents(snapshot.value)), + Stream.merge(updates, terminal, { haltStrategy: "either" }), + ); + }), + ).pipe( + Stream.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Stream.failCause(cause); + } + // The supervisor keeps the original cause. Shared durable consumers + // need a transport-shaped failure so they wait for its replacement. + return Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: `${connection.label} config subscription failed.`, + cause, + }), + }), + ); + }), ); + const subscribeServerConfig = (input: ServerConfigSubscriptionInput) => + Equal.equals(input, serverConfigInput) + ? serverConfigEvents + : protocolClient[WS_METHODS.subscribeServerConfig](input); const probe = initialConfig.pipe( Effect.flatMap((config) => (config.environment.capabilities.connectionProbe === true - ? client[WS_METHODS.serverProbe]({}) - : client[WS_METHODS.serverGetConfig]({}) + ? protocolClient[WS_METHODS.serverProbe]({}) + : protocolClient[WS_METHODS.serverGetConfig]({}) ).pipe(Effect.mapError(mapRpcError)), ), Effect.asVoid, @@ -145,19 +349,24 @@ const make = Effect.gen(function* () { ); return { - client, + client: protocolClient, initialConfig, + subscribeServerConfig, ready: Deferred.await(connected).pipe( Effect.andThen(initialConfig), Effect.asVoid, Effect.raceFirst(Deferred.await(disconnected)), ), probe, - closed: Deferred.await(disconnected), + closed: Effect.raceFirst( + Deferred.await(disconnected), + Deferred.await(configSubscriptionClosed), + ), } satisfies RpcSession; }); return RpcSessionFactory.of({ connect }); }); -export const layer = Layer.effect(RpcSessionFactory, make); +export const layerWithOptions = (options: RpcSessionOptions) => + Layer.effect(RpcSessionFactory, make(options)); diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 2e0e01f58..6a22b22f1 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -34,6 +34,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 123731aa9..a2cc959d0 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -32,7 +32,6 @@ import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { - applyServerConfigProjection, applyServerWelcomeEvent, makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, @@ -50,6 +49,7 @@ import { waitForDesktopUpdateTarget, runDesktopCommitWithReconnectObserver, } from "./server.ts"; +import { applyServerConfigProjection } from "./serverConfigProjection.ts"; const CONFIG = { availableEditors: [], @@ -81,6 +81,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.succeed(CONFIG), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index c58bba0ec..e1ecf4085 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -46,6 +46,14 @@ import { } from "../rpc/client.ts"; import type { RpcSession } from "../rpc/session.ts"; import { followStreamInEnvironment } from "./runtime.ts"; +import { + applyServerConfigProjection, + type ServerConfigProjection, + withoutEnvironmentThemes, +} from "./serverConfigProjection.ts"; + +// Exported server state includes this type in its inferred public return type. +export type { ServerConfigProjection } from "./serverConfigProjection.ts"; export type ServerUpdateStage = "downloading" | "installing" | "resuming"; @@ -342,119 +350,12 @@ export function resolveServerUpdateProgressResult( return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion })); } -export interface ServerConfigProjection { - readonly config: ServerConfig; - readonly latestEvent: ServerConfigStreamEvent; - readonly source: "cache" | "live"; -} - -/** - * Cached config keeps the provider and model catalog available across reconnects. - * Published themes and usage-limit sources are current machine state, so a - * cache could restore a set the machine no longer reports. Replay sends both - * as separate events. - */ -function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { - if (config.environmentThemes === undefined && config.usageLimitSources === undefined) { - return config; - } - const { environmentThemes: _themes, usageLimitSources: _sources, ...rest } = config; - return rest; -} - -export function applyServerConfigProjection( - current: Option.Option, - event: ServerConfigStreamEvent, -): Option.Option { - switch (event.type) { - case "snapshot": { - // Wire snapshots never contain published themes. Keep the previous set - // until a capable server sends its authoritative theme event. A legacy - // server cannot send a later removal, so a downgrade must clear the set. - const capabilities = event.config.environment.capabilities; - const carriedThemes = - capabilities.environmentThemes === true && Option.isSome(current) - ? current.value.config.environmentThemes - : undefined; - const carriedSources = - capabilities.usageLimitSources === true && Option.isSome(current) - ? current.value.config.usageLimitSources - : undefined; - return Option.some({ - config: { - ...event.config, - ...(carriedThemes === undefined ? {} : { environmentThemes: carriedThemes }), - ...(carriedSources === undefined ? {} : { usageLimitSources: carriedSources }), - }, - latestEvent: event, - source: "live" as const, - }); - } - case "keybindingsUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - keybindings: event.payload.keybindings, - issues: event.payload.issues, - }, - latestEvent: event, - source: "live", - })); - case "providerStatuses": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - providers: event.payload.providers, - }, - latestEvent: event, - source: "live", - })); - case "settingsUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - settings: event.payload.settings, - }, - latestEvent: event, - source: "live", - })); - case "environmentThemesUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, - }, - latestEvent: event, - source: "live", - })); - case "usageLimitSourcesUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - usageLimitSources: event.payload.sources.length > 0 ? event.payload.sources : undefined, - }, - latestEvent: event, - source: "live", - })); - } -} - const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ version: 1, type: "snapshot", config, }); -/** - * Keeps a complete server configuration available during reconnects. Server - * config carries the provider/model catalogue used by task creation, so it is - * useful—and safe—to retain after a transport session ends. - */ -/** - * Published themes live only as long as the machine publishes them, so they - * must not survive in the config cache: a restart or an offline load would - * otherwise hand clients palettes the environment has already dropped. - */ export interface ServerConfigSubscriptionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts new file mode 100644 index 000000000..778839803 --- /dev/null +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -0,0 +1,99 @@ +import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +export interface ServerConfigProjection { + readonly config: ServerConfig; + readonly latestEvent: ServerConfigStreamEvent; + readonly source: "cache" | "live"; +} + +/** + * Cached config keeps the provider and model catalog available across reconnects. + * Published themes and usage-limit sources are current machine state, so a + * cache could restore a set the machine no longer reports. Replay sends both + * as separate events. + */ +export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { + if (config.environmentThemes === undefined && config.usageLimitSources === undefined) { + return config; + } + const { environmentThemes: _themes, usageLimitSources: _sources, ...rest } = config; + return rest; +} + +export function applyServerConfigProjection( + current: Option.Option, + event: ServerConfigStreamEvent, +): Option.Option { + switch (event.type) { + case "snapshot": { + // Wire snapshots never contain published themes. Keep the previous set + // until a capable server sends its authoritative theme event. A legacy + // server cannot send a later removal, so a downgrade must clear the set. + const capabilities = event.config.environment.capabilities; + const carriedThemes = + capabilities.environmentThemes === true && Option.isSome(current) + ? current.value.config.environmentThemes + : undefined; + const carriedSources = + capabilities.usageLimitSources === true && Option.isSome(current) + ? current.value.config.usageLimitSources + : undefined; + return Option.some({ + config: { + ...event.config, + ...(carriedThemes === undefined ? {} : { environmentThemes: carriedThemes }), + ...(carriedSources === undefined ? {} : { usageLimitSources: carriedSources }), + }, + latestEvent: event, + source: "live" as const, + }); + } + case "keybindingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + keybindings: event.payload.keybindings, + issues: event.payload.issues, + }, + latestEvent: event, + source: "live", + })); + case "providerStatuses": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + providers: event.payload.providers, + }, + latestEvent: event, + source: "live", + })); + case "settingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + settings: event.payload.settings, + }, + latestEvent: event, + source: "live", + })); + case "environmentThemesUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, + }, + latestEvent: event, + source: "live", + })); + case "usageLimitSourcesUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + usageLimitSources: event.payload.sources.length > 0 ? event.payload.sources : undefined, + }, + latestEvent: event, + source: "live", + })); + } +} diff --git a/packages/client-runtime/src/state/serverUsage.test.ts b/packages/client-runtime/src/state/serverUsage.test.ts index bb961d95d..9cf2667d8 100644 --- a/packages/client-runtime/src/state/serverUsage.test.ts +++ b/packages/client-runtime/src/state/serverUsage.test.ts @@ -101,6 +101,7 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( const session: RpcSession = { client, initialConfig: Effect.succeed(CONFIG), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 4899095cf..0d933c39f 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -52,6 +52,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index 393be8e32..33c566bf8 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -50,6 +50,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 62c7cd257..27229a7af 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -129,6 +129,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? threadResumeCompletionMarker: true, threadSnapshotPagination: true, } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-failures.test.ts b/packages/client-runtime/src/state/threads-failures.test.ts index 73e16909c..df590ac9b 100644 --- a/packages/client-runtime/src/state/threads-failures.test.ts +++ b/packages/client-runtime/src/state/threads-failures.test.ts @@ -106,6 +106,7 @@ const makeHarness = Effect.fn("TestThreadFailures.makeHarness")(function* (optio const session: RpcSession = { client, initialConfig: Effect.succeed({ threadResumeCompletionMarker: true } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts index 62cad18f8..2cede4f5b 100644 --- a/packages/client-runtime/src/state/threads-pagination.test.ts +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -156,6 +156,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt initialConfig: Effect.succeed({ threadSnapshotPagination: options?.paginationCapability !== false, } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 7fbfc159a..619b5f434 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -116,6 +116,7 @@ function testSession( ? ({ threadResumeCompletionMarker: true } as never) : ({} as never), ), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 0a6264c62..d7a4692fc 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -86,6 +86,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index b936246dc..905972975 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -84,6 +84,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, From bcd5e1a928a740274fe17bf954581f77451446cc Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 11 Sep 2026 01:49:27 -0600 Subject: [PATCH 06/10] feat(desktop): add configurable quit shortcut confirmation `confirmQuit` was a boolean whose enabled state accepted either a hold or a second press without saying so. It is now an explicit mode: Direct quits on the first Cmd/Ctrl+Q press, Hold (the default) quits after a completed hold or a quick second press, and Double press quits on two presses within 500 ms and shows a "Press again" hint after the first. Settings uses a selector, the overlay shows the hint for the selected mode, and the preload and IPC contract carry the mode with each hint. Stored booleans migrate on read, true to Hold and false to Direct, on both browser storage and the desktop settings file, and save back as the mode string. T3 Code writes the same modes to shared browser storage, so those values now decode instead of falling back to the default. The desktop still reads the mode through `readConfirmQuit`, which keeps Hold when the settings file cannot be read. The lenient-decoding tests now use another setting as their unreadable example. Adopted from 9d1879b142a2f5d01383357646a4679d1a2bd202 (#9076) --- apps/desktop/src/preload.ts | 16 +- .../settings/DesktopClientSettings.test.ts | 31 +- .../src/settings/DesktopClientSettings.ts | 5 +- apps/desktop/src/window/DesktopWindow.ts | 17 +- apps/desktop/src/window/QuitHold.test.ts | 273 +++++++++++++----- apps/desktop/src/window/QuitHold.ts | 114 +++++--- apps/web/src/clientPersistenceStorage.test.ts | 17 +- apps/web/src/components/QuitHoldOverlay.tsx | 35 ++- .../components/settings/SettingsPanels.tsx | 43 ++- .../src/components/settings/settingsSearch.ts | 4 +- docs/user/keybindings.md | 20 +- packages/contracts/src/ipc.ts | 13 +- packages/contracts/src/settings.test.ts | 46 ++- packages/contracts/src/settings.ts | 26 +- 14 files changed, 467 insertions(+), 193 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 06884c4ce..d55673151 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -174,9 +174,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, onQuitShortcut: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { - if (state !== "down" && state !== "up") return; - listener(state); + const wrappedListener = (_event: Electron.IpcRendererEvent, hint: unknown) => { + if (typeof hint !== "object" || hint === null || !("state" in hint)) return; + if (hint.state === "up") { + listener({ state: "up" }); + return; + } + if ( + hint.state === "down" && + "mode" in hint && + (hint.mode === "hold" || hint.mode === "double-click") + ) { + listener({ state: "down", mode: hint.mode }); + } }; ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 10f0331b7..2b16c7ef6 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -27,7 +27,7 @@ const clientSettings: ClientSettings = { browserAutoShowFloatingPreview: false, browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], browserDefaultProfileId: "work", - confirmQuit: true, + confirmQuit: "double-click", confirmThreadArchive: true, confirmThreadDelete: false, confirmThreadUnpin: false, @@ -344,22 +344,24 @@ describe("DesktopClientSettings", () => { yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString( environment.clientSettingsPath, - '{"confirmQuit":"hold","timestampFormat":"12-hour"}', + '{"confirmQuit":false,"diffLayout":"unified","timestampFormat":"12-hour"}', ); const saved = Option.getOrThrow(yield* settings.get); - assert.isTrue(saved.confirmQuit); + // A legacy boolean is readable: it migrates to its mode and saves canonically. + assert.strictEqual(saved.confirmQuit, "direct"); yield* settings.set({ ...saved, onboardingCompletedAt: "2026-09-10T12:00:00.000Z" }); assert.deepInclude(yield* readDocument, { - confirmQuit: "hold", + confirmQuit: "direct", + diffLayout: "unified", timestampFormat: "12-hour", onboardingCompletedAt: "2026-09-10T12:00:00.000Z", }); - yield* settings.set({ ...saved, confirmQuit: false }); - assert.deepInclude(yield* readDocument, { confirmQuit: false }); - yield* settings.set({ ...saved, confirmQuit: false, wordWrap: false }); - assert.deepInclude(yield* readDocument, { confirmQuit: false, wordWrap: false }); + yield* settings.set({ ...saved, diffLayout: "split" }); + assert.deepInclude(yield* readDocument, { diffLayout: "split" }); + yield* settings.set({ ...saved, diffLayout: "split", wordWrap: false }); + assert.deepInclude(yield* readDocument, { diffLayout: "split", wordWrap: false }); }), ), ); @@ -376,13 +378,18 @@ describe("DesktopClientSettings", () => { ), set: () => Effect.void, }); - const disabled = DesktopClientSettings.DesktopClientSettings.of({ - get: Effect.succeed(Option.some({ ...clientSettings, confirmQuit: false })), + const direct = DesktopClientSettings.DesktopClientSettings.of({ + get: Effect.succeed(Option.some({ ...clientSettings, confirmQuit: "direct" })), + set: () => Effect.void, + }); + const missing = DesktopClientSettings.DesktopClientSettings.of({ + get: Effect.succeed(Option.none()), set: () => Effect.void, }); - assert.isTrue(yield* DesktopClientSettings.readConfirmQuit(failing)); - assert.isFalse(yield* DesktopClientSettings.readConfirmQuit(disabled)); + assert.strictEqual(yield* DesktopClientSettings.readConfirmQuit(failing), "hold"); + assert.strictEqual(yield* DesktopClientSettings.readConfirmQuit(missing), "hold"); + assert.strictEqual(yield* DesktopClientSettings.readConfirmQuit(direct), "direct"); }), ); }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index f8085edbd..d97205582 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -4,6 +4,7 @@ import { encodeStoredClientSettings, retainUnreadClientSettings, type ClientSettings, + type QuitConfirmationMode, type StoredClientSettings, } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; @@ -134,10 +135,10 @@ const readClientSettings = ( ), ); -/** Reads the hold-to-quit preference, keeping the default hold when settings cannot be read. */ +/** Reads the quit shortcut mode, keeping the default hold when settings cannot be read. */ export const readConfirmQuit = ( clientSettings: DesktopClientSettings["Service"], -): Effect.Effect => +): Effect.Effect => clientSettings.get.pipe( Effect.map( Option.match({ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 5dbaf32f5..2fb7ff9eb 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -28,7 +28,7 @@ import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; -import { makeQuitHoldHandler } from "./QuitHold.ts"; +import { makeQuitShortcutHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -607,15 +607,14 @@ export const make = Effect.gen(function* () { // close-terminal shortcut can outlive the terminal that handled its first // press, so reject repeats before they reach the native window accelerator. // Deliberate presses still flow through the renderer or native menu. - // Chrome-style hold-to-quit: intercept the quit accelerator before the - // native menu sees it and only quit after the shortcut is held. The - // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. - const quitHoldHandler = makeQuitHoldHandler({ + // Intercept the quit accelerator before the native menu sees it and apply + // the configured direct, hold, or double-press behavior. + const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, - isEnabled: () => runPromise(DesktopClientSettings.readConfirmQuit(clientSettings)), - notify: (state) => { + getMode: () => runPromise(DesktopClientSettings.readConfirmQuit(clientSettings)), + notify: (hint) => { if (!window.isDestroyed()) { - window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); + window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); } }, // Keep the transparent window focused until the physical shortcut is @@ -626,7 +625,7 @@ export const make = Effect.gen(function* () { }, }); window.webContents.on("before-input-event", (event, input) => { - quitHoldHandler(event, input); + quitShortcutHandler(event, input); if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index 84dbf76f0..58809d8eb 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -1,12 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { - makeQuitHoldHandler, - QUIT_DOUBLE_TAP_MS, + makeQuitShortcutHandler, + QUIT_DOUBLE_PRESS_MS, QUIT_HOLD_DURATION_MS, QUIT_HOLD_RELEASE_GRACE_MS, } from "./QuitHold.ts"; -import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; +import type { QuitHoldKeyInput } from "./QuitHold.ts"; +import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; + +const HOLD_DOWN = { state: "down", mode: "hold" } as const; +const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const; +const UP = { state: "up" } as const; function makeInput(overrides: Partial): QuitHoldKeyInput { return { @@ -22,24 +27,24 @@ function makeInput(overrides: Partial): QuitHoldKeyInput { } function makeHarness(options?: { - enabled?: boolean; + mode?: QuitConfirmationMode; platform?: NodeJS.Platform; - isEnabled?: () => Promise; + getMode?: () => Promise; }) { - const notifications: Array = []; + const notifications: Array = []; const concealWindow = vi.fn(); const quit = vi.fn(); - const handler = makeQuitHoldHandler({ + const handler = makeQuitShortcutHandler({ platform: options?.platform ?? "darwin", - isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), - notify: (state) => notifications.push(state), + getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")), + notify: (event) => notifications.push(event), concealWindow, quit, }); const preventDefault = vi.fn(); const send = async (input: QuitHoldKeyInput) => { handler({ preventDefault }, input); - // Let the isEnabled promise settle. + // Let the getMode promise settle. await Promise.resolve(); await Promise.resolve(); }; @@ -57,7 +62,7 @@ function makeHarness(options?: { return { notifications, concealWindow, quit, preventDefault, send, holdFor }; } -describe("makeQuitHoldHandler", () => { +describe("makeQuitShortcutHandler", () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -71,12 +76,12 @@ describe("makeQuitHoldHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); expect(harness.preventDefault).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down"]); + expect(harness.notifications).toEqual([HOLD_DOWN]); vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).not.toHaveBeenCalled(); // The watchdog dismisses the hint once the press is clearly over. - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("conceals a completed hold, then quits after release", async () => { @@ -89,7 +94,7 @@ describe("makeQuitHoldHandler", () => { expect(harness.quit).not.toHaveBeenCalled(); vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("keeps a concealed hold committed when another key is pressed", async () => { @@ -129,7 +134,7 @@ describe("makeQuitHoldHandler", () => { vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("waits for slow repeats to stop before quitting", async () => { @@ -186,7 +191,7 @@ describe("makeQuitHoldHandler", () => { await harness.send(makeInput({})); await harness.holdFor(500); await harness.send(makeInput({ type: "keyUp" })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); expect(harness.concealWindow).not.toHaveBeenCalled(); expect(harness.quit).not.toHaveBeenCalled(); @@ -196,112 +201,208 @@ describe("makeQuitHoldHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); expect(harness.quit).not.toHaveBeenCalled(); }); - it("quits without showing a hint when hold-to-quit is disabled", async () => { - const harness = makeHarness({ enabled: false }); + it("quits without showing a hint in direct mode", async () => { + const harness = makeHarness({ mode: "direct" }); await harness.send(makeInput({})); expect(harness.concealWindow).not.toHaveBeenCalled(); expect(harness.quit).toHaveBeenCalledTimes(1); expect(harness.notifications).toEqual([]); }); - it("discards a stale isEnabled resolution from a superseded press", async () => { - // Press #1's isEnabled is still pending when the user releases and + it("honors direct mode when the key is released before its mode read settles", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolveMode?.("direct"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }); + + it("does not arm hold mode after a released key's mode read settles", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolveMode?.("hold"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it.each(["direct", "hold", "double-click"] as const)( + "quits on a quick second press without waiting for a pending %s mode read", + async (mode) => { + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; + const harness = makeHarness({ + getMode: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + + await harness.send(makeInput({ type: "keyUp" })); + + resolvers[0]?.(mode); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }, + ); + + it("discards a stale mode resolution from a superseded press", async () => { + // Press #1's mode is still pending when the user releases and // presses again; its late resolution must not act for press #2. - const resolvers: Array<(enabled: boolean) => void> = []; + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; const harness = makeHarness({ - isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), + getMode: () => new Promise((resolve) => resolvers.push(resolve)), }); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - // Outside the double-tap window, so the second press starts a new hold. - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + // Outside the double-press window, so the second press starts a new hold. + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); await harness.send(makeInput({})); expect(resolvers).toHaveLength(2); - // Press #1 resolves late with "disabled" — it must not quit press #2. - resolvers[0]?.(false); + // Press #1 resolves late with "direct". It must not quit press #2. + resolvers[0]?.("direct"); await Promise.resolve(); await Promise.resolve(); expect(harness.quit).not.toHaveBeenCalled(); - // Press #2 resolves enabled and completes a full hold. - resolvers[1]?.(true); + // Press #2 resolves to hold and completes the gesture. + resolvers[1]?.("hold"); await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); await harness.send(makeInput({ type: "keyUp" })); expect(harness.quit).toHaveBeenCalledTimes(1); }); - it("quits on a quick double tap, even when the first release was never seen", async () => { - const harness = makeHarness(); + it("quits on a quick double press in double-click mode when the first release is unseen", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); expect(harness.concealWindow).not.toHaveBeenCalled(); expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); }); - it("treats two slow taps as separate presses", async () => { - const harness = makeHarness(); + it("keeps the double-press hint visible after key release until the window ends", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + vi.advanceTimersByTime(100); + await harness.send(makeInput({ type: "keyUp" })); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 101); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + vi.advanceTimersByTime(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("accepts a second full shortcut after the modifier is released and pressed again", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + vi.advanceTimersByTime(100); + + await harness.send(makeInput({ key: "Meta" })); await harness.send(makeInput({})); - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); }); - it("cancels the hold when another key interrupts it", async () => { - const harness = makeHarness(); + it("expires a delayed double-press hint from keydown rather than mode resolution", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); await harness.send(makeInput({})); - await harness.holdFor(500); - // Shift pressed mid-hold breaks the gesture... - await harness.send(makeInput({ shift: true })); - expect(harness.notifications).toEqual(["down", "up"]); - // ...so later repeats past the threshold must not quit. - await harness.holdFor(QUIT_HOLD_DURATION_MS); - expect(harness.quit).not.toHaveBeenCalled(); + vi.advanceTimersByTime(100); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(100); + resolveMode?.("double-click"); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 201); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + vi.advanceTimersByTime(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); }); - it("does not count an interrupted press toward a double tap", async () => { - const harness = makeHarness(); + it("treats two slow presses as separate attempts in double-click mode", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); - await harness.send(makeInput({ shift: true })); - // A fresh press right after the interruption starts a new hold, not a - // double-tap quit. + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); - it("ignores other shortcuts", async () => { + it("quits on a quick second press in hold mode", async () => { const harness = makeHarness(); - await harness.send(makeInput({ key: "w" })); - await harness.send(makeInput({ shift: true })); - await harness.send(makeInput({ meta: false })); - expect(harness.preventDefault).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([]); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); - it("uses control on non-mac platforms", async () => { - const harness = makeHarness({ platform: "linux" }); - await harness.send(makeInput({ meta: false, control: true })); - expect(harness.preventDefault).toHaveBeenCalledTimes(1); - await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); - await harness.send(makeInput({ type: "keyUp", meta: false, control: true })); + it("quits on a quick second press in hold mode when the first release is unseen", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + + expect(harness.concealWindow).not.toHaveBeenCalled(); expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); + it("does not count auto-repeat as a second press", async () => { const harness = makeHarness(); await harness.send(makeInput({})); - await harness.holdFor(QUIT_DOUBLE_TAP_MS - 100); + await harness.holdFor(QUIT_DOUBLE_PRESS_MS - 100); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down"]); + expect(harness.notifications).toEqual([HOLD_DOWN]); }); it("does not count a released tap after another shortcut interrupts it", async () => { @@ -313,20 +414,46 @@ describe("makeQuitHoldHandler", () => { await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); }); - it("accepts a second full shortcut after the modifier is released and pressed again", async () => { + it("cancels the hold when another key interrupts it", async () => { const harness = makeHarness(); await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - vi.advanceTimersByTime(100); + await harness.holdFor(500); + // Shift pressed mid-hold breaks the gesture... + await harness.send(makeInput({ shift: true })); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); + // ...so later repeats past the threshold must not quit. + await harness.holdFor(QUIT_HOLD_DURATION_MS); + expect(harness.quit).not.toHaveBeenCalled(); + }); - await harness.send(makeInput({ key: "Meta" })); + it("does not count an interrupted press toward a double press", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + await harness.send(makeInput({ shift: true })); + // A fresh press right after the interruption starts a new attempt. await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); + }); + it("ignores other shortcuts", async () => { + const harness = makeHarness(); + await harness.send(makeInput({ key: "w" })); + await harness.send(makeInput({ shift: true })); + await harness.send(makeInput({ meta: false })); + expect(harness.preventDefault).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it("uses control on non-mac platforms", async () => { + const harness = makeHarness({ platform: "linux" }); + await harness.send(makeInput({ meta: false, control: true })); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); + await harness.send(makeInput({ type: "keyUp", meta: false, control: true })); expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down", "up"]); }); }); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index dc1516e61..a995184dd 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -1,28 +1,24 @@ // @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. -// Chrome-style hold-to-quit. The quit accelerator is intercepted in -// before-input-event (which runs before the native menu accelerator), and the -// app only quits after the shortcut has been held for QUIT_HOLD_DURATION_MS -// and released. -// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap -// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application -// menu itself is untouched and quits immediately. +import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; + +// The quit accelerator is intercepted in before-input-event, which runs +// before the native menu accelerator. Quitting from the application menu is +// untouched and always quits immediately. export const QUIT_HOLD_DURATION_MS = 1200; -// A second quick tap of the shortcut is the user insisting: quit immediately. -export const QUIT_DOUBLE_TAP_MS = 500; +export const QUIT_DOUBLE_PRESS_MS = 500; // "Still held" is proven by auto-repeat keydowns, not by the absence of a // release: macOS suppresses a letter keyUp while the command key is down, so a // tap release can go completely unseen and a release-based timer would quit // anyway. Once held, quitting waits for Q keyUp or a quiet grace period after // repeats stop so they cannot reach the next app. Keyboards with -// auto-repeat disabled use two quick presses or the application menu Quit action. +// auto-repeat disabled must use a double press or the application menu Quit action. +// Supporting holds without repeats requires a native physical key-state check. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. const QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER = 2; -export type QuitHoldState = "down" | "up"; - export interface QuitHoldKeyInput { readonly type: string; readonly key: string; @@ -33,30 +29,32 @@ export interface QuitHoldKeyInput { readonly isAutoRepeat: boolean; } -export interface QuitHoldOptions { +export interface QuitShortcutOptions { readonly platform: NodeJS.Platform; - readonly isEnabled: () => Promise; - readonly notify: (state: QuitHoldState) => void; + readonly getMode: () => Promise; + readonly notify: (event: QuitShortcutHintEvent) => void; readonly concealWindow: () => void; readonly quit: () => void; } -export function makeQuitHoldHandler( - options: QuitHoldOptions, +export function makeQuitShortcutHandler( + options: QuitShortcutOptions, ): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { const modifierKey = options.platform === "darwin" ? "meta" : "control"; let watchdog: NodeJS.Timeout | undefined; let holding = false; - // Set once isEnabled resolves true; auto-repeats may only complete the hold when armed. + let mode: QuitConfirmationMode | undefined; + let notified = false; + // Set once getMode resolves to hold; auto-repeats may only complete the hold when armed. let armed = false; let quitOnRelease = false; let heldSince = 0; let lastPressAt = 0; let lastRepeatAt = 0; let repeatCadenceMs = 0; - // Incremented on every new press and every release/quit so a pending - // isEnabled() resolution from a superseded press cannot arm (or quit for) - // the current one. + // Incremented when a press is superseded or explicitly cancelled. A plain + // key release does not invalidate its pending mode read: a direct-mode + // press must still quit after that read settles. let generation = 0; const clearWatchdog = () => { @@ -66,21 +64,26 @@ export function makeQuitHoldHandler( } }; - const release = () => { - if (!holding) return; - const shouldNotify = armed || quitOnRelease; - generation += 1; + const release = (cancelPendingMode = true, keepDoublePressHint = false) => { + if (cancelPendingMode) generation += 1; + if (!holding && !notified) return; + const keepHint = keepDoublePressHint && mode === "double-click" && notified; holding = false; armed = false; quitOnRelease = false; lastRepeatAt = 0; repeatCadenceMs = 0; + if (keepHint) return; + + mode = undefined; clearWatchdog(); - if (shouldNotify) options.notify("up"); + if (notified) { + notified = false; + options.notify({ state: "up" }); + } }; - // Dismisses any overlay first: if the quit is cancelled downstream the - // renderer must not be left with a stuck "Hold to Quit" hint. + // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. const quitNow = () => { release(); lastPressAt = 0; @@ -101,11 +104,11 @@ export function makeQuitHoldHandler( if (input.type === "keyUp") { if (key === "q") { const shouldQuit = quitOnRelease; - release(); + release(false, true); if (shouldQuit) options.quit(); } else if (key === modifierKey) { if (!quitOnRelease) { - release(); + release(false, true); } else { quitAfterQuietPeriod(); } @@ -133,9 +136,13 @@ export function makeQuitHoldHandler( } if (!modifierDown || input.alt || input.shift || key !== "q") { - // Re-pressing the platform modifier starts a second full shortcut. + // Re-pressing the platform modifier is the first half of a second full + // quit shortcut, so it must not cancel an active double-press window. if (key === modifierKey && !input.alt && !input.shift) return; - // Other keys cancel the first tap even if its release already arrived. + + // Other keys cancel the hold and the first tap, even after release. + // Keep this separate from release(), which also runs when a fresh Q + // keydown follows a keyUp that macOS did not deliver. if (!input.isAutoRepeat) { lastPressAt = 0; release(); @@ -146,7 +153,7 @@ export function makeQuitHoldHandler( event.preventDefault(); if (input.isAutoRepeat) { - if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { + if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { armed = false; quitOnRelease = true; options.concealWindow(); @@ -158,28 +165,49 @@ export function makeQuitHoldHandler( const now = Date.now(); const previousPressAt = lastPressAt; lastPressAt = now; - // A fresh keydown while "holding" means the key came back down after a - // release macOS never delivered — so both branches below see real taps. - if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { + // A fresh keydown supersedes the current physical hold or the hint kept + // alive after a detected release. + if (holding || notified) release(); + + generation += 1; + // Every mode accepts two presses. Quit before reading settings so a slow + // read cannot delay the second press. Repeats never reach this branch. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { quitNow(); return; } - if (holding) release(); - generation += 1; const pressGeneration = generation; holding = true; heldSince = now; - void options.isEnabled().then( - (enabled) => { + void options.getMode().then( + (resolvedMode) => { if (generation !== pressGeneration) return; - if (!enabled) { - // Hold-to-quit disabled: a single press quits immediately. + if (resolvedMode === "direct") { quitNow(); return; } + if (resolvedMode === "double-click") { + const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now); + if (remainingMs <= 0) { + release(); + return; + } + mode = resolvedMode; + notified = true; + options.notify({ state: "down", mode: resolvedMode }); + watchdog = setTimeout(release, remainingMs); + return; + } + + // A hold cannot be armed after its physical press has ended. + if (!holding) return; + + mode = resolvedMode; + notified = true; + options.notify({ state: "down", mode: resolvedMode }); + armed = true; - options.notify("down"); // No auto-repeat by then means the key was released (possibly with a // suppressed keyUp) or repeat is disabled; either way, don't quit. watchdog = setTimeout(() => { diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 95a024c9d..e9e218884 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -77,9 +77,11 @@ describe("clientPersistenceStorage", () => { }, ); - // T3 Code served from the same origin writes `confirmQuit` as "hold" | "direct" | "double-click". + // Another client served from the same origin, such as T3 Code, can write a + // value this build cannot decode next to values it can. const t3CodeDocument = JSON.stringify({ - confirmQuit: "hold", + confirmQuit: "double-click", + diffLayout: "unified", timestampFormat: "12-hour", wordWrap: false, }); @@ -93,6 +95,7 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual({ ...DEFAULT_CLIENT_SETTINGS, + confirmQuit: "double-click", timestampFormat: "12-hour", wordWrap: false, }); @@ -111,6 +114,7 @@ describe("clientPersistenceStorage", () => { await ensureClientSettingsHydrated(); expect(getClientSettings()).toEqual({ ...DEFAULT_CLIENT_SETTINGS, + confirmQuit: "double-click", timestampFormat: "12-hour", wordWrap: false, }); @@ -120,15 +124,16 @@ describe("clientPersistenceStorage", () => { onboardingCompletedAt: "2026-09-10T12:00:00.000Z", })); expect(storedDocument()).toMatchObject({ - confirmQuit: "hold", + confirmQuit: "double-click", + diffLayout: "unified", timestampFormat: "12-hour", onboardingCompletedAt: "2026-09-10T12:00:00.000Z", }); - await persistClientSettingsUpdate((current) => ({ ...current, confirmQuit: false })); - expect(storedDocument()).toMatchObject({ confirmQuit: false }); + await persistClientSettingsUpdate((current) => ({ ...current, diffLayout: "split" })); + expect(storedDocument()).toMatchObject({ diffLayout: "split" }); await persistClientSettingsUpdate((current) => ({ ...current, wordWrap: true })); - expect(storedDocument()).toMatchObject({ confirmQuit: false, wordWrap: true }); + expect(storedDocument()).toMatchObject({ diffLayout: "split", wordWrap: true }); }); it("preserves saved settings across a transient read failure", async () => { diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx index 7cc4d184f..2bca40f5b 100644 --- a/apps/web/src/components/QuitHoldOverlay.tsx +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -2,29 +2,34 @@ import { useEffect, useState } from "react"; import { isMacPlatform } from "../lib/utils"; -// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint -// from a quick tap lingers for as long as a full hold would have taken. -const HIDE_AFTER_RELEASE_MS = 1200; +// A released hold hint lingers for the original hold duration. Double-press +// hints disappear as soon as their acceptance window closes. +const HOLD_HINT_LINGER_MS = 1200; /** - * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts - * the quit accelerator and pushes press/release states; a quick tap shows - * this pill while a full hold quits the app. + * The desktop main process intercepts the quit accelerator and pushes + * press/release states while it waits for a hold or second press. */ export function QuitHoldOverlay() { - const [visible, setVisible] = useState(false); + const [visibleMode, setVisibleMode] = useState<"hold" | "double-click" | null>(null); useEffect(() => { const subscribe = window.desktopBridge?.onQuitShortcut; if (!subscribe) return; let hideTimer: number | undefined; - const unsubscribe = subscribe((state) => { + let pressedMode: "hold" | "double-click" = "hold"; + const unsubscribe = subscribe((hint) => { window.clearTimeout(hideTimer); - if (state === "down") { - setVisible(true); + if (hint.state === "down") { + pressedMode = hint.mode; + setVisibleMode(hint.mode); return; } - hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); + if (pressedMode === "double-click") { + setVisibleMode(null); + return; + } + hideTimer = window.setTimeout(() => setVisibleMode(null), HOLD_HINT_LINGER_MS); }); return () => { window.clearTimeout(hideTimer); @@ -32,15 +37,19 @@ export function QuitHoldOverlay() { }; }, []); - if (!visible) return null; + if (!visibleMode) return null; const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; + const message = + visibleMode === "hold" + ? `Hold ${shortcut} or press twice to quit` + : `Press ${shortcut} again to quit`; return (
- Hold {shortcut} or press twice to quit + {message}
); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f8073d305..c8befd4c9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -38,6 +38,7 @@ import { MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, + type QuitConfirmationMode, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; @@ -182,6 +183,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const QUIT_CONFIRMATION_MODE_LABELS: Record = { + direct: "Direct", + hold: "Hold", + "double-click": "Double press", +}; + const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { balanced: "Balanced", performance: "Performance", @@ -563,9 +570,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), - ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit - ? ["Quit confirmation"] - : []), + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ...getChangedBrowserSettingLabels(settings), ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess @@ -2712,11 +2717,11 @@ export function GeneralSettingsPanel() { {isElectron ? ( updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) } @@ -2724,11 +2729,29 @@ export function GeneralSettingsPanel() { ) : null } control={ - updateSettings({ confirmQuit: Boolean(checked) })} - aria-label="Hold to quit" - /> + } /> ) : null} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index af2b912d7..bc6796801 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -287,9 +287,9 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "quit-confirmation", - title: "Hold to quit", + title: "Quit shortcut", to: "/settings/general", - searchTerms: ["confirmation shortcut desktop app exit"], + searchTerms: ["confirmation desktop app exit direct hold double click press twice"], desktopOnly: true, }, { diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 91d2ce566..7e4250efe 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -103,14 +103,22 @@ but the new thread does not reuse the worktree created for the thread that just ## Desktop quit shortcut -Use `Cmd+Q` on macOS or `Ctrl+Q` on Windows and Linux. With **Hold to quit** enabled, -hold the shortcut for 1.2 seconds or press it twice within 500 milliseconds. The second -press quits immediately. You can keep Command or Control held between presses, or release -both keys. An unrelated shortcut cancels the first tap. +Use `Cmd+Q` on macOS or `Ctrl+Q` on Windows and Linux. **Settings** → **General** → +**Confirmations** → **Quit shortcut** chooses how it confirms: + +- **Hold** (the default): hold the shortcut for 1.2 seconds, or press it twice within + 500 milliseconds. A single quick press shows a hint instead of quitting. +- **Double press**: press the shortcut twice within 500 milliseconds. The first press + shows a hint until that window ends. +- **Direct**: the first press quits. + +The second press quits immediately. You can keep Command or Control held between presses, +or release both keys. An unrelated shortcut cancels the first press. Holding needs keyboard repeat. If holding does not quit, use two quick presses or choose -**Quit** from the application menu. Turn off **Hold to quit** in **Settings** → **General** -to quit on the first press. The application menu's **Quit** action always quits immediately. +**Quit** from the application menu. The application menu's **Quit** action always quits +immediately. If Pylon had **Hold to quit** turned off before this setting existed, it starts +in **Direct**. ## `when` Conditions diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 93ac435fa..c4176b33c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -98,7 +98,7 @@ import type { import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; -import { type ClientSettings, SnapShotShortcut } from "./settings.ts"; +import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts"; import type { EditorId } from "./editor.ts"; import type { SourceControlCloneRepositoryInput, @@ -127,6 +127,10 @@ export interface ContextMenuItem { children?: readonly ContextMenuItem[]; } +export type QuitShortcutHintEvent = + | { readonly state: "down"; readonly mode: Exclude } + | { readonly state: "up" }; + export interface ContextMenuItemSchemaType { readonly id: string; readonly label: string; @@ -1301,11 +1305,10 @@ export interface DesktopBridge { onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent?: (listener: (event: DesktopSnapShotEvent) => void) => () => void; /** - * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, - * "up" when it is released before the hold completes. Optional: older - * desktop builds never emit it. + * Quit-confirmation hint pushes. Optional: older desktop builds never emit + * them. */ - onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; + onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index dfa836fe0..456a89d05 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -167,6 +167,42 @@ describe("ClientSettings retired status motion", () => { }); }); +describe("ClientSettings quit confirmation", () => { + it("defaults to hold", () => { + expect(decodeClientSettings({}).confirmQuit).toBe("hold"); + }); + + it.each(["direct", "hold", "double-click"] as const)("accepts the %s mode", (mode) => { + expect(decodeClientSettings({ confirmQuit: mode }).confirmQuit).toBe(mode); + expect(decodeClientSettingsPatch({ confirmQuit: mode }).confirmQuit).toBe(mode); + }); + + it.each([ + [true, "hold"], + [false, "direct"], + ] as const)("migrates the legacy %s value to %s", (legacyValue, mode) => { + const settings = decodeClientSettings({ confirmQuit: legacyValue }); + + expect(settings.confirmQuit).toBe(mode); + expect(encodeClientSettings(settings).confirmQuit).toBe(mode); + }); + + it("rejects legacy booleans at the patch boundary", () => { + expect(() => decodeClientSettingsPatch({ confirmQuit: true })).toThrow(); + }); + + it("reads a stored mode or legacy boolean without leaving it unread", () => { + expect(decodeStoredClientSettings({ confirmQuit: "double-click" })).toEqual({ + settings: { ...DEFAULT_CLIENT_SETTINGS, confirmQuit: "double-click" }, + unreadValues: {}, + }); + expect(decodeStoredClientSettings({ confirmQuit: false })).toEqual({ + settings: { ...DEFAULT_CLIENT_SETTINGS, confirmQuit: "direct" }, + unreadValues: {}, + }); + }); +}); + describe("stored client settings", () => { it("returns null for a document that is not a settings object", () => { expect(decodeStoredClientSettings("settings")).toBeNull(); @@ -176,29 +212,29 @@ describe("stored client settings", () => { it("defaults only the values it cannot decode and retains them until they change", () => { const stored = decodeStoredClientSettings({ - confirmQuit: "hold", + diffLayout: "unified", fontSizeCode: "large", timestampFormat: "12-hour", }); expect(stored).toEqual({ settings: { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" }, - unreadValues: { confirmQuit: "hold", fontSizeCode: "large" }, + unreadValues: { diffLayout: "unified", fontSizeCode: "large" }, }); const unchanged = retainUnreadClientSettings({ ...stored!.settings, wordWrap: false }, stored); expect(encodeStoredClientSettings(unchanged)).toMatchObject({ - confirmQuit: "hold", + diffLayout: "unified", fontSizeCode: "large", timestampFormat: "12-hour", wordWrap: false, }); const changed = retainUnreadClientSettings( - { ...unchanged.settings, confirmQuit: false }, + { ...unchanged.settings, diffLayout: "split" }, unchanged, ); expect(changed.unreadValues).toEqual({ fontSizeCode: "large" }); expect(encodeStoredClientSettings(changed)).toMatchObject({ - confirmQuit: false, + diffLayout: "split", fontSizeCode: "large", }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f41dd69f0..cbab5deac 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -151,6 +151,22 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); +export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; +const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; + +const LegacyConfirmQuit = Schema.Boolean.pipe( + Schema.decodeTo( + QuitConfirmationMode, + SchemaTransformation.transform({ + decode: (confirmQuit): QuitConfirmationMode => (confirmQuit ? "hold" : "direct"), + encode: (mode) => mode === "hold", + }), + ), +); + +const QuitConfirmationModeSetting = Schema.Union([QuitConfirmationMode, LegacyConfirmQuit]); + export const SnapShotKeyChord = KeybindingShortcut.check( Schema.makeFilter( (shortcut) => @@ -304,9 +320,11 @@ export const ClientSettingsSchema = Schema.Struct({ browserDefaultProfileId: BrowserProfileId.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_PROFILE_ID)), ), - // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the - // app quits; a quick tap only shows a hint. Browser clients ignore it. - confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Desktop-only. Boolean values from older settings files decode to their + // equivalent mode and encode back as the canonical string value. + confirmQuit: QuitConfirmationModeSetting.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)), + ), 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))), @@ -1488,7 +1506,7 @@ export const ClientSettingsPatch = Schema.Struct({ browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)), browserDefaultProfileId: Schema.optionalKey(BrowserProfileId), - confirmQuit: Schema.optionalKey(Schema.Boolean), + confirmQuit: Schema.optionalKey(QuitConfirmationMode), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), From 6b163708a6bbba7a4a4ad1ed3e3c21dce47c32a3 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 11 Sep 2026 01:50:52 -0600 Subject: [PATCH 07/10] feat(web): open project settings from thread menus Thread menus in the sidebar, the legacy sidebar, and the chat header now include Project settings, which opens the thread's project at `/projects/$projectKey` (resolving grouped projects to their logical key). The sidebar project row keeps its own settings entry, the web context menu fallback gains the settings icon those entries use, and mobile is unchanged, as upstream. Adopted from cb007469161ff0db2bc2dc8123c4b30e186aae50 (#8925) --- apps/web/src/components/LegacySidebar.tsx | 14 ++++++ apps/web/src/components/Sidebar.tsx | 36 +++++++++++---- .../components/threadActionMenu.logic.test.ts | 13 +++++- .../src/components/threadActionMenu.logic.ts | 2 + apps/web/src/contextMenuFallback.ts | 9 ++++ apps/web/src/hooks/useThreadActionMenu.ts | 44 ++++++++++++++++++- 6 files changed, 108 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ff205e4ea..c388891e7 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2268,11 +2268,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "project-settings", label: "Project settings" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], position, ); + if (clicked === "project-settings") { + if (isMobile) setOpenMobile(false); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey: project.projectKey }, + }); + return; + } + if (clicked === "new-thread-on-branch") { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -2355,9 +2365,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard, deleteThread, handleNewThread, + isMobile, markThreadUnread, memberProjectByScopedKey, + project.projectKey, project.workspaceRoot, + router, + setOpenMobile, startThreadRename, ], ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index adfe6130d..d2a48ccc9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2333,6 +2333,8 @@ export default function Sidebar() { () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); + const projectGroupsRef = useRef(projectGroups); + projectGroupsRef.current = projectGroups; const serverConfigs = useAtomValue(environmentServerConfigsAtom); // Threads on non-primary environments (T3 Connect, hosted) resolve their // provider entry from their own environment's config: default instance ids @@ -2485,6 +2487,18 @@ export default function Sidebar() { clearSelection(); }, [clearSelection, projectScopeKey]); + const openProjectSettings = useCallback( + (projectGroup: SidebarProjectSnapshot) => { + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey: projectGroup.projectKey }, + }); + }, + [isMobile, router, setOpenMobile], + ); // Safari may dispatch a selection click after the context menu opens settings. const suppressNextScopeChangeRef = useRef(false); const highlightedProjectScopeKeyRef = useRef(null); @@ -2497,15 +2511,9 @@ export default function Sidebar() { event.stopPropagation(); suppressNextScopeChangeRef.current = true; dispatchProjectScopeMenu({ type: "project-settings-opened" }); - if (isMobile) { - setOpenMobile(false); - } - void router.navigate({ - to: "/projects/$projectKey", - params: { projectKey: projectGroup.projectKey }, - }); + openProjectSettings(projectGroup); }, - [isMobile, router, setOpenMobile], + [openProjectSettings], ); // Keep a dropped row at its destination while its server applies the @@ -4058,6 +4066,17 @@ export default function Sidebar() { return; } switch (clicked.value) { + case "project-settings": { + const projectGroup = projectGroupsRef.current.find((group) => + group.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === thread.environmentId && + projectRef.projectId === thread.projectId, + ), + ); + if (projectGroup) openProjectSettings(projectGroup); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -4217,6 +4236,7 @@ export default function Sidebar() { deleteThread, handleMultiSelectContextMenu, markThreadUnread, + openProjectSettings, projectByKey, serverConfigs, startThreadRename, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 96931bc8f..1bdd04693 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -33,7 +33,18 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy", "archive", "delete"]); + ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]); + }); + + it("groups project settings with utility actions before archive", () => { + const items = buildThreadActionMenuItems(baseState); + const copyIndex = items.findIndex((item) => item.id === "copy"); + expect(items[copyIndex + 1]).toMatchObject({ + id: "project-settings", + label: "Project settings", + icon: "settings", + }); + expect(items[copyIndex + 2]?.id).toBe("archive"); }); it("includes branch items only for threads with a branch", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index df983ee86..5ba266f77 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -8,6 +8,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" */ export type ThreadActionMenuId = | "new-thread-on-branch" + | "project-settings" | "pin" | "unpin" | "settle" @@ -119,6 +120,7 @@ export function buildThreadActionMenuItems( { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, ], }, + { id: "project-settings", label: "Project settings", icon: "settings" }, // Archive removes the thread from the sidebar while keeping its // conversation under Settings > Archived threads — distinct from Settle // (stays visible in the Settled shelf) and Delete (clears history for diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index d3bb9400c..c6cfc4498 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -96,6 +96,15 @@ const ICON_PATHS: Record void; }) { const { threadRef, projectCwd, onStartRename } = input; + const router = useRouter(); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const logicalProjectKeyByPhysicalKey = useMemo( + () => + buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }), + [primaryEnvironmentId, projectGroupingSettings, projects], + ); const { settleThread, unsettleThread, @@ -168,6 +190,22 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "project-settings": { + const project = projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && + candidate.id === thread.projectId, + ); + if (!project) return; + const projectKey = + logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey }, + }); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -300,10 +338,14 @@ export function useThreadActionMenu(input: { copyThreadIdToClipboard, deleteThread, handleNewThread, + logicalProjectKeyByPhysicalKey, markThreadUnread, onStartRename, pinThread, projectCwd, + projectGroupingSettings, + projects, + router, settleThread, snoozeThread, threadRef, From 494b8272e4da08bfe061ea3aada2bb074235030f Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 11 Sep 2026 01:51:12 -0600 Subject: [PATCH 08/10] docs(web): mention project settings in thread menus Adopted from cb007469161ff0db2bc2dc8123c4b30e186aae50 (#8925) --- docs/user/project-settings.md | 4 ++-- docs/user/thread-sidebar.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 897754a6f..4785ae38a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -1,8 +1,8 @@ # Project settings Open **Settings → Projects**. The project and machine pickers start at **All projects** and -**All machines**. You can also open a project's settings from the sidebar project filter, the -chat header, or the command palette. +**All machines**. You can also open a project's settings from the sidebar project filter, a +thread's menu, the chat header, or the command palette. With **All projects** selected, change the default model, workspace, automatic pull, agent browser access, or actions for projects that inherit those values. Select an individual project to override diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 9a55de68e..2fe1b305b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -56,8 +56,8 @@ search results: the thread opens and the files are attached in its composer, rea message. Nothing is sent automatically. The same per-message file limits apply as when attaching files directly; see [Composer](./composer.md). -Project settings are available from the project menu in either sidebar and from the breadcrumb -context menu when composing a new thread. +Project settings are available from the project menu in either sidebar, from any thread's menu, +and from the breadcrumb context menu when composing a new thread. ## Mobile thread list From 38acb86298c69c30278dd84798b1226c7aa337c6 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 11 Sep 2026 01:54:15 -0600 Subject: [PATCH 09/10] docs(upstream): record pre-cursor gap source dispositions --- .agents/upstream-review.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index a29ad027f..1c2de5aa3 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -63,6 +63,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Remote media fallback, recording transfer, browser context menus, MCP snapshot output and macOS installer art / `6c583620ff7ad3235b135af7107c0543467eecfa` | `a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3` (#10619), `9e37f0c291974d084a59d7b9f165a1cb056e6043` (#10572), `b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9` (#10670), `061543e9e5b54ec0048725c37d52fef2962df173` (#10501), `991526383f95eefdd66ffd2853136c6dd34ae008` (#10632), `5d14c0e9686d8bedf47dd93dd3811fd639255c7e` (#10819), `0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3` (#10820) | All seven adopted. Desktop retries missing drive or POSIX absolute media from remote threads (relative images arrive workspace-resolved) against its primary environment; UNC and device paths never fall back, client or server. Agent recordings upload once to a remote requesting environment, while the desktop's own primary environment gets the saved path with no copy (older desktops get an update-required error). Pylon adaptations: transferred recordings and `save: true` screenshots live in `browser-artifacts//` so rollbacks keep them and thread deletion removes them; `preview_snapshot` keeps upstream's read-only/idempotent hints because saves stay in that per-thread store; Codex `tool_timeout_sec` and OpenCode `timeout` are 180 s for Pylon's MCP server, Claude and ACP providers keep their defaults. #10501 uses `Schema.TaggedErrorClass` for Pylon's Effect. DMG layout, sizes and art follow upstream (Nightly aurora raster has no marks); both variants carry the Pylon mark and "Drag Pylon into Applications." instead of T3's wordmark; titles, artifact names and signing unchanged. Split from window capture #8103, which lands separately. Cursor unchanged. | [Desktop fixes #462](https://github.com/pylon-code/pylon/pull/462); shared 6, client-runtime 50, server MCP/asset/config/pipeline/adapter 308 + router 5, web 79, desktop 126 and build-script 80 tests; contracts, shared, client-runtime, t3, web, desktop, mobile and scripts typechecks; scoped lint/format. | | Files in question answers / `6c583620ff7ad3235b135af7107c0543467eecfa` | `7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49` (#9871) | Adopted across contracts, server, web, desktop and mobile: `attachmentsByQuestionId` on respond commands and events, the `questionAttachments` capability, normalizer claims, `user-input.answer-submitted` history, provider answer path lines in `ProviderService`, and a separate `projection.attachment-cleanup` cursor that retains answer files through reverts and deletes. Per adapter: Codex, Claude, Cursor, Grok and OpenCode receive path lines; Antigravity questions never accept custom answers, so the decider refuses their attachments; Prime asks no user-input questions. Pylon adaptation: normalizer and failed-dispatch cleanup keep `thread.input-queue.follow-up`; mobile readiness uses the #10404 upload helpers from #460 as upstream does. Pylon diverges on cleanup mechanics, which upstream shares at the frozen head: the live path writes the cleanup cursor with the projector cursors at the last finished cleanup; bootstrap selects only revert/delete rows past it without decoding payloads, lists the attachments directory once and skips threads without files (1,000 deleted threads with 3,000 files: 28.8 s to 2 ms); a database without the row starts at its lowest projector cursor; file errors are logged and retried once on the next start instead of pinning the cursor; revert retention reads only answer activities; unused `minLastAppliedSequence` removed. Mobile discards question drafts only on live thread data and explains a paste refused by an older server. Claude, Cursor, Grok and OpenCode are code-path verified; upstream live-tested Codex. Cursor unchanged. | [Question attachments #463](https://github.com/pylon-code/pylon/pull/463); 868 focused tests (28 files: server 16, web 7, mobile 5), five package typechecks, scoped lint/format, before/after bootstrap probe. | | Desktop window capture (SnapShots), Electron 44 and post-Electron 43 recording / `6c583620ff7ad3235b135af7107c0543467eecfa` | `299404a754f52c02c69634528d8856b3c93b378c` (#8103), `9fe4d6568b5e4852f6085d3a22dbc5aa3a632408` (#10645), `ef7014d851f56bb037a9da963095ffd883c7fa08` (#9001), `4a9d2d0ced2a2b899dbee9e4a5162fd83f81edb8` (#8626, partial); skipped `8de9169f078fdafedf47d8c299cb8e2c79fda6fb` (v0.0.40 release bump) | Adopted opt-in capture on macOS, Windows and Wayland (KDE/Hyprland helpers, GNOME extension, Niri, portal), setup/settings, attachment metadata and fenced prompt context, plus CI/release steps for both crates. Electron 41.5.0 moves to 44.1.0 (maintainer approved): packaged macOS builds require 13.0 and mac update manifests carry `minimumSystemVersion` 22.0.0 so macOS 12 keeps its build; the runtime repair script uses Electron's checksummed installer, checks its fallback download against the package's bundled checksums and reinstalls partial runtimes, and desktop tests install the runtime once in a Vitest global setup; pickers without a default path reopen in the last picked directory; #8626 is superseded except its required `websql` removal. #9001 restores preview recording via `getDisplayMedia` arming and keeps inactive macOS guests paintable, preserving Pylon's hardware-first bitrate recorder, #10403 `ideal` frame rate, stream cleanup, lock timeouts and z-index presentation; #462's recording-transfer tests now use its capture trigger. Agent screenshots and snapshots keep `capturePage` without `stayHidden` (unchanged 41.5.0 to 44.1.0) under a surface lease that composites the guest inside the window. Maintainer-approved rename to per-channel `com.pylon.code[.nightly|.dev].desktop` (XDG portals need a dotted app ID) with matching WM class, AppImage `StartupWMClass` and entry icon; the legacy `pylon-code-url-handler.desktop` is removed once the scheme default moves; AGENTS.md updated. Pylon-owned coexistence identifiers: `snap-shot@pylon-code.com` / `PylonSnapShot` extension, channel-keyed `pylon-kde-snap-shot`/`pylon-hyprland-snap-shot` installs and Pylon Niri endpoint; Pylon copy. Captured-window context also rides Pylon follow-ups; undecodable capture metadata drops only the source; `accessibleText` is dropped when structured accessibility exists (icon/tree sidecar deferred until payload size is measured as a problem; the double `SnapShotSource` decode in `ForwardCompatibleOptional` is accepted until capture-heavy threads show decode cost); over-quota drafts no longer re-deliver captures and undeliverable captures are reported once; accessibility details format only when opened; Settings -> Keybindings warns on the global capture chord. Cursor unchanged. | [Window capture #461](https://github.com/pylon-code/pylon/pull/461); after rebasing on `origin/pylon` (through #477): desktop 629 (23 Linux-only skipped), web 584, contracts 164, server 131 (`ProviderService` and question-attachment suites), build-script/manifest/native TS 96 tests; seven Electron-loading desktop suites pass from a fresh or partial `dist`; cargo fmt/test on Linux (resource-monitor 18, KDE 11, Hyprland 10); contracts, shared, client-runtime, t3, web, desktop and mobile typechecks; scoped lint/format. | +| Pre-cursor gap sources before `beae2147a9`: previously unrecorded / `6c583620ff7ad3235b135af7107c0543467eecfa` | `8dcb96314c976899e4df6951fb9af03131c2a46f` (#8733), `8b817cbcaad71a53e2ef73f3881067f8aa8094bc` (#8840), `7963ac7404ff2196c3e8e4198ecc02a5e742b0a1` (v0.0.37), `ad38700ac678b8c8a0310d434a44d94a7ee6a47f` (#8917), `4e8e64fc065a4a72535eee5fe60b689f5b48d35c` (#8933), `5ce92c2f192040bf77c0211fa33bf03c74c031ef` (#8932), `f47e74004af232f0e3df8dc10093601d1c2c3ea3` (#8851), `0df043fd4eaa190eb491a3060836156eb0ae915e`, `85b656ff300f71060ad6305c7e1e29a72b442ce9`, `c78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc` (#8085), `ce71c04f0aa9d2e5cd340e2a04cb1b0d5e24419d` (#8936), `0947c30e6946b2ad6d6cd518fd44292e75e834e8` (#9010), `b883fc066ea5c9bebbe1c3e9b4bc2471aab3685f` (#8367), `9dbdcece5f488c66f6b9ac516b610f45bbbb676a` (#9033), `9d1879b142a2f5d01383357646a4679d1a2bd202` (#9076), `cb007469161ff0db2bc2dc8123c4b30e186aae50` (#8925), `692eb1a5792b9930959b19805acf2bf2611318c9` (#9092), `60cef47ec983637ddc68faed7b1488b6f3c3a175` (v0.0.38) | Adopted: #8085 names remote web session cookies from the persisted environment ID, migrates a valid legacy `t3_session` and gives CLI auth a launcher-free identity layer; #8367 bootstraps from one config subscription, with Pylon's usage-limit source and `/usage-limits` options on web and mobile; #9076 adds Direct, Hold and Double press quit modes, migrating booleans (true to Hold, false to Direct) in browser storage and the desktop file while `readConfirmQuit` keeps its Hold fallback; #8925 adds Project settings to web thread menus; #8851 keeps Pylon's left-run branch layout. Partial: #8932 shimmer clipping only, its shimmer derivation superseded by #10173 and #10273; #8936 web tool grouping now shared with client-runtime, its viewed-image assets already landed with the #9023 port. Already covered: #9010; the #8917 conventions rule (via #9321), with its approvability file skipped. Superseded: #8733 by #8734 (#221), #8840 by #9606, #9033 by #8890 (#402), #9092 by #9332. Skipped: both release bumps and the three CodeRabbit configs; neither CodeRabbit nor Macroscope runs on Pylon PRs. Cursor unchanged. | [Pre-cursor gaps #PRNUM](https://github.com/pylon-code/pylon/pull/PRNUM); focused tests (server 88, client-runtime 213, web 460, contracts 112, desktop 43), six package typechecks, scoped lint and format. | ## Deferred register From 688aed55f223f45c0b9fdf677c084eed153194b9 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 11 Sep 2026 01:56:04 -0600 Subject: [PATCH 10/10] docs(upstream): link the pre-cursor gap record to #481 --- .agents/upstream-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 1c2de5aa3..5ee479362 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -63,7 +63,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Remote media fallback, recording transfer, browser context menus, MCP snapshot output and macOS installer art / `6c583620ff7ad3235b135af7107c0543467eecfa` | `a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3` (#10619), `9e37f0c291974d084a59d7b9f165a1cb056e6043` (#10572), `b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9` (#10670), `061543e9e5b54ec0048725c37d52fef2962df173` (#10501), `991526383f95eefdd66ffd2853136c6dd34ae008` (#10632), `5d14c0e9686d8bedf47dd93dd3811fd639255c7e` (#10819), `0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3` (#10820) | All seven adopted. Desktop retries missing drive or POSIX absolute media from remote threads (relative images arrive workspace-resolved) against its primary environment; UNC and device paths never fall back, client or server. Agent recordings upload once to a remote requesting environment, while the desktop's own primary environment gets the saved path with no copy (older desktops get an update-required error). Pylon adaptations: transferred recordings and `save: true` screenshots live in `browser-artifacts//` so rollbacks keep them and thread deletion removes them; `preview_snapshot` keeps upstream's read-only/idempotent hints because saves stay in that per-thread store; Codex `tool_timeout_sec` and OpenCode `timeout` are 180 s for Pylon's MCP server, Claude and ACP providers keep their defaults. #10501 uses `Schema.TaggedErrorClass` for Pylon's Effect. DMG layout, sizes and art follow upstream (Nightly aurora raster has no marks); both variants carry the Pylon mark and "Drag Pylon into Applications." instead of T3's wordmark; titles, artifact names and signing unchanged. Split from window capture #8103, which lands separately. Cursor unchanged. | [Desktop fixes #462](https://github.com/pylon-code/pylon/pull/462); shared 6, client-runtime 50, server MCP/asset/config/pipeline/adapter 308 + router 5, web 79, desktop 126 and build-script 80 tests; contracts, shared, client-runtime, t3, web, desktop, mobile and scripts typechecks; scoped lint/format. | | Files in question answers / `6c583620ff7ad3235b135af7107c0543467eecfa` | `7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49` (#9871) | Adopted across contracts, server, web, desktop and mobile: `attachmentsByQuestionId` on respond commands and events, the `questionAttachments` capability, normalizer claims, `user-input.answer-submitted` history, provider answer path lines in `ProviderService`, and a separate `projection.attachment-cleanup` cursor that retains answer files through reverts and deletes. Per adapter: Codex, Claude, Cursor, Grok and OpenCode receive path lines; Antigravity questions never accept custom answers, so the decider refuses their attachments; Prime asks no user-input questions. Pylon adaptation: normalizer and failed-dispatch cleanup keep `thread.input-queue.follow-up`; mobile readiness uses the #10404 upload helpers from #460 as upstream does. Pylon diverges on cleanup mechanics, which upstream shares at the frozen head: the live path writes the cleanup cursor with the projector cursors at the last finished cleanup; bootstrap selects only revert/delete rows past it without decoding payloads, lists the attachments directory once and skips threads without files (1,000 deleted threads with 3,000 files: 28.8 s to 2 ms); a database without the row starts at its lowest projector cursor; file errors are logged and retried once on the next start instead of pinning the cursor; revert retention reads only answer activities; unused `minLastAppliedSequence` removed. Mobile discards question drafts only on live thread data and explains a paste refused by an older server. Claude, Cursor, Grok and OpenCode are code-path verified; upstream live-tested Codex. Cursor unchanged. | [Question attachments #463](https://github.com/pylon-code/pylon/pull/463); 868 focused tests (28 files: server 16, web 7, mobile 5), five package typechecks, scoped lint/format, before/after bootstrap probe. | | Desktop window capture (SnapShots), Electron 44 and post-Electron 43 recording / `6c583620ff7ad3235b135af7107c0543467eecfa` | `299404a754f52c02c69634528d8856b3c93b378c` (#8103), `9fe4d6568b5e4852f6085d3a22dbc5aa3a632408` (#10645), `ef7014d851f56bb037a9da963095ffd883c7fa08` (#9001), `4a9d2d0ced2a2b899dbee9e4a5162fd83f81edb8` (#8626, partial); skipped `8de9169f078fdafedf47d8c299cb8e2c79fda6fb` (v0.0.40 release bump) | Adopted opt-in capture on macOS, Windows and Wayland (KDE/Hyprland helpers, GNOME extension, Niri, portal), setup/settings, attachment metadata and fenced prompt context, plus CI/release steps for both crates. Electron 41.5.0 moves to 44.1.0 (maintainer approved): packaged macOS builds require 13.0 and mac update manifests carry `minimumSystemVersion` 22.0.0 so macOS 12 keeps its build; the runtime repair script uses Electron's checksummed installer, checks its fallback download against the package's bundled checksums and reinstalls partial runtimes, and desktop tests install the runtime once in a Vitest global setup; pickers without a default path reopen in the last picked directory; #8626 is superseded except its required `websql` removal. #9001 restores preview recording via `getDisplayMedia` arming and keeps inactive macOS guests paintable, preserving Pylon's hardware-first bitrate recorder, #10403 `ideal` frame rate, stream cleanup, lock timeouts and z-index presentation; #462's recording-transfer tests now use its capture trigger. Agent screenshots and snapshots keep `capturePage` without `stayHidden` (unchanged 41.5.0 to 44.1.0) under a surface lease that composites the guest inside the window. Maintainer-approved rename to per-channel `com.pylon.code[.nightly|.dev].desktop` (XDG portals need a dotted app ID) with matching WM class, AppImage `StartupWMClass` and entry icon; the legacy `pylon-code-url-handler.desktop` is removed once the scheme default moves; AGENTS.md updated. Pylon-owned coexistence identifiers: `snap-shot@pylon-code.com` / `PylonSnapShot` extension, channel-keyed `pylon-kde-snap-shot`/`pylon-hyprland-snap-shot` installs and Pylon Niri endpoint; Pylon copy. Captured-window context also rides Pylon follow-ups; undecodable capture metadata drops only the source; `accessibleText` is dropped when structured accessibility exists (icon/tree sidecar deferred until payload size is measured as a problem; the double `SnapShotSource` decode in `ForwardCompatibleOptional` is accepted until capture-heavy threads show decode cost); over-quota drafts no longer re-deliver captures and undeliverable captures are reported once; accessibility details format only when opened; Settings -> Keybindings warns on the global capture chord. Cursor unchanged. | [Window capture #461](https://github.com/pylon-code/pylon/pull/461); after rebasing on `origin/pylon` (through #477): desktop 629 (23 Linux-only skipped), web 584, contracts 164, server 131 (`ProviderService` and question-attachment suites), build-script/manifest/native TS 96 tests; seven Electron-loading desktop suites pass from a fresh or partial `dist`; cargo fmt/test on Linux (resource-monitor 18, KDE 11, Hyprland 10); contracts, shared, client-runtime, t3, web, desktop and mobile typechecks; scoped lint/format. | -| Pre-cursor gap sources before `beae2147a9`: previously unrecorded / `6c583620ff7ad3235b135af7107c0543467eecfa` | `8dcb96314c976899e4df6951fb9af03131c2a46f` (#8733), `8b817cbcaad71a53e2ef73f3881067f8aa8094bc` (#8840), `7963ac7404ff2196c3e8e4198ecc02a5e742b0a1` (v0.0.37), `ad38700ac678b8c8a0310d434a44d94a7ee6a47f` (#8917), `4e8e64fc065a4a72535eee5fe60b689f5b48d35c` (#8933), `5ce92c2f192040bf77c0211fa33bf03c74c031ef` (#8932), `f47e74004af232f0e3df8dc10093601d1c2c3ea3` (#8851), `0df043fd4eaa190eb491a3060836156eb0ae915e`, `85b656ff300f71060ad6305c7e1e29a72b442ce9`, `c78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc` (#8085), `ce71c04f0aa9d2e5cd340e2a04cb1b0d5e24419d` (#8936), `0947c30e6946b2ad6d6cd518fd44292e75e834e8` (#9010), `b883fc066ea5c9bebbe1c3e9b4bc2471aab3685f` (#8367), `9dbdcece5f488c66f6b9ac516b610f45bbbb676a` (#9033), `9d1879b142a2f5d01383357646a4679d1a2bd202` (#9076), `cb007469161ff0db2bc2dc8123c4b30e186aae50` (#8925), `692eb1a5792b9930959b19805acf2bf2611318c9` (#9092), `60cef47ec983637ddc68faed7b1488b6f3c3a175` (v0.0.38) | Adopted: #8085 names remote web session cookies from the persisted environment ID, migrates a valid legacy `t3_session` and gives CLI auth a launcher-free identity layer; #8367 bootstraps from one config subscription, with Pylon's usage-limit source and `/usage-limits` options on web and mobile; #9076 adds Direct, Hold and Double press quit modes, migrating booleans (true to Hold, false to Direct) in browser storage and the desktop file while `readConfirmQuit` keeps its Hold fallback; #8925 adds Project settings to web thread menus; #8851 keeps Pylon's left-run branch layout. Partial: #8932 shimmer clipping only, its shimmer derivation superseded by #10173 and #10273; #8936 web tool grouping now shared with client-runtime, its viewed-image assets already landed with the #9023 port. Already covered: #9010; the #8917 conventions rule (via #9321), with its approvability file skipped. Superseded: #8733 by #8734 (#221), #8840 by #9606, #9033 by #8890 (#402), #9092 by #9332. Skipped: both release bumps and the three CodeRabbit configs; neither CodeRabbit nor Macroscope runs on Pylon PRs. Cursor unchanged. | [Pre-cursor gaps #PRNUM](https://github.com/pylon-code/pylon/pull/PRNUM); focused tests (server 88, client-runtime 213, web 460, contracts 112, desktop 43), six package typechecks, scoped lint and format. | +| Pre-cursor gap sources before `beae2147a9`: previously unrecorded / `6c583620ff7ad3235b135af7107c0543467eecfa` | `8dcb96314c976899e4df6951fb9af03131c2a46f` (#8733), `8b817cbcaad71a53e2ef73f3881067f8aa8094bc` (#8840), `7963ac7404ff2196c3e8e4198ecc02a5e742b0a1` (v0.0.37), `ad38700ac678b8c8a0310d434a44d94a7ee6a47f` (#8917), `4e8e64fc065a4a72535eee5fe60b689f5b48d35c` (#8933), `5ce92c2f192040bf77c0211fa33bf03c74c031ef` (#8932), `f47e74004af232f0e3df8dc10093601d1c2c3ea3` (#8851), `0df043fd4eaa190eb491a3060836156eb0ae915e`, `85b656ff300f71060ad6305c7e1e29a72b442ce9`, `c78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc` (#8085), `ce71c04f0aa9d2e5cd340e2a04cb1b0d5e24419d` (#8936), `0947c30e6946b2ad6d6cd518fd44292e75e834e8` (#9010), `b883fc066ea5c9bebbe1c3e9b4bc2471aab3685f` (#8367), `9dbdcece5f488c66f6b9ac516b610f45bbbb676a` (#9033), `9d1879b142a2f5d01383357646a4679d1a2bd202` (#9076), `cb007469161ff0db2bc2dc8123c4b30e186aae50` (#8925), `692eb1a5792b9930959b19805acf2bf2611318c9` (#9092), `60cef47ec983637ddc68faed7b1488b6f3c3a175` (v0.0.38) | Adopted: #8085 names remote web session cookies from the persisted environment ID, migrates a valid legacy `t3_session` and gives CLI auth a launcher-free identity layer; #8367 bootstraps from one config subscription, with Pylon's usage-limit source and `/usage-limits` options on web and mobile; #9076 adds Direct, Hold and Double press quit modes, migrating booleans (true to Hold, false to Direct) in browser storage and the desktop file while `readConfirmQuit` keeps its Hold fallback; #8925 adds Project settings to web thread menus; #8851 keeps Pylon's left-run branch layout. Partial: #8932 shimmer clipping only, its shimmer derivation superseded by #10173 and #10273; #8936 web tool grouping now shared with client-runtime, its viewed-image assets already landed with the #9023 port. Already covered: #9010; the #8917 conventions rule (via #9321), with its approvability file skipped. Superseded: #8733 by #8734 (#221), #8840 by #9606, #9033 by #8890 (#402), #9092 by #9332. Skipped: both release bumps and the three CodeRabbit configs; neither CodeRabbit nor Macroscope runs on Pylon PRs. Cursor unchanged. | [Pre-cursor gaps #481](https://github.com/pylon-code/pylon/pull/481); focused tests (server 88, client-runtime 213, web 460, contracts 112, desktop 43), six package typechecks, scoped lint and format. | ## Deferred register