From 14bf3f6d1644a37029be58429e8f0138e1ceb743 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 04:20:08 -0700 Subject: [PATCH 001/262] fix(web): toggle a single stashed prompt with Cmd+S (#9644) Cmd+S opened the stash menu even when the composer was empty and only one prompt was stashed. It now restores that prompt directly, so repeated presses toggle between the draft and stash. Multiple entries and images that are still saving open the menu. The stash badge still opens the menu. Validation: 94 focused stash, shortcut, and attachment tests pass. Web typecheck and formatting pass. Targeted lint has no new warnings or errors. Browser checks were skipped at Theo's request. Original implementation by Theo Browne. No code changes were needed during the takeover audit. Audited with GPT-6 Astra (preview) in Codex. --- apps/web/src/components/chat/ChatComposer.tsx | 9 ++++++++- apps/web/src/components/chat/ComposerStashMenu.tsx | 8 ++++---- docs/user/composer.md | 6 ++++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0b034d44338c..946d150bb0ac 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3336,7 +3336,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const images = [...composerImagesRef.current]; const files = [...composerFilesRef.current]; if (prompt.length === 0 && images.length === 0 && files.length === 0) { - setIsStashMenuOpen((open) => !open); + const entries = usePromptStashStore.getState().entries; + const entry = entries.length === 1 ? entries[0] : undefined; + if (entry && !entry.pendingImageCount) { + await restoreStashEntry(entry); + } else { + setIsStashMenuOpen((open) => !open); + } return; } const stashedFiles: PersistedComposerFileAttachment[] = []; @@ -3523,6 +3529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) finalizeStashEntryImages, promptRef, pulseStashBadge, + restoreStashEntry, stashEntryToQueue, ]); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 69032764c1f4..4942b21dd600 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -30,10 +30,10 @@ function stashEntrySnippet(entry: PromptStashEntry): string { } /** - * Attached banner listing the stashed prompts. Keyboard-first: opened by โŒ˜S on an - * empty composer, navigated with arrows, restored with Enter, dismissed - * with Escape. The listener runs capture-phase on window so it wins over - * the Lexical editor's handlers while the menu is open. + * Attached banner listing the stashed prompts. Opened by the stash badge or โŒ˜S + * when the empty composer cannot restore a single entry. Navigated with arrows, + * restored with Enter, dismissed with Escape. The listener runs capture-phase + * on window so it wins over the Lexical editor's handlers while the menu is open. */ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { entries: ReadonlyArray; diff --git a/docs/user/composer.md b/docs/user/composer.md index fce133cd27d8..2e98a21f7198 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -182,8 +182,10 @@ open the stack. Interacting with the attached banner or composer does not open t ## Prompt stash Use the default shortcut, `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux, to stash the current -prompt and its attachments after all file uploads finish. Restore the entry later from the stash -menu. Stashes that contain files must be restored in the environment where those files were +prompt and its attachments after all file uploads finish. When the composer is empty and the stash +has one entry, press the shortcut again to restore it. The shortcut opens the stash menu if there +are multiple entries or the entry's images are still saving. You can also open the menu from the +stash badge. Stashes that contain files must be restored in the environment where those files were uploaded. Stashed files stay uploaded on the server for 24 hours. If you restore an entry after that, the file comes back with **Attach again** next to it. Attach the file again or remove it, then send. From eb77683e5544e071db74831bae052bbd8a7d5f88 Mon Sep 17 00:00:00 2001 From: seeb1337 <63622047+seeb1337@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:20:53 +0200 Subject: [PATCH 002/262] fix(server): prevent duplicate desktop clients after restart Replace stale local desktop sessions in one transaction. Preserve paired clients and browser sessions, and keep the previous credential valid if replacement fails. Closes https://github.com/pingdotgg/t3code/issues/6283. Original implementation by seeb1337. Reviewed and verified with GPT-6 Astra (preview) in Codex. Co-authored-by: seeb1337 <63622047+seeb1337@users.noreply.github.com> Co-authored-by: Theo Browne --- apps/server/src/auth/EnvironmentAuth.test.ts | 77 ++++++++++++++++++++ apps/server/src/auth/EnvironmentAuth.ts | 3 + apps/server/src/auth/SessionStore.test.ts | 74 +++++++++++++++++++ apps/server/src/auth/SessionStore.ts | 57 ++++++++++----- apps/server/src/persistence/AuthSessions.ts | 49 +++++++++++++ apps/server/src/server.test.ts | 32 ++++++++ docs/internals/environment-auth.md | 6 ++ docs/user/remote-access.md | 4 + 8 files changed, 284 insertions(+), 18 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 6e5f22fa3af6..028fe53e0191 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -52,6 +52,18 @@ const makeCookieRequest = ( EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] >[0]; +const makeBearerRequest = ( + token: string, +): Parameters[0] => + ({ + cookies: {}, + headers: { + authorization: `Bearer ${token}`, + }, + }) as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + const requestMetadata = { deviceType: "desktop" as const, os: "macOS", @@ -159,6 +171,71 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("rotates desktop bearer sessions without accumulating authorized clients", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const browser = yield* serverAuth.createBrowserSession( + "desktop-bootstrap-token", + requestMetadata, + ); + const browserSession = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(sessions.cookieName, browser.sessionToken), + ); + const staleSessions = yield* Effect.forEach([1, 2, 3], () => + sessions.issue({ subject: "desktop-bootstrap", method: "bearer-access-token" }), + ); + const pairing = yield* serverAuth.issuePairingCredential(); + const paired = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + pairing.credential, + undefined, + { ...requestMetadata, label: "T3 Code Desktop" }, + ); + const first = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + "desktop-bootstrap-token", + undefined, + requestMetadata, + ); + const firstSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(first.access_token), + ); + const second = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + "desktop-bootstrap-token", + undefined, + requestMetadata, + ); + + const active = yield* serverAuth.listSessions(); + const firstError = yield* serverAuth + .authenticateHttpRequest(makeBearerRequest(first.access_token)) + .pipe(Effect.flip); + const secondSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(second.access_token), + ); + + expect(active).toHaveLength(3); + expect(active.map((entry) => entry.sessionId)).toContain(browserSession.sessionId); + expect(active.map((entry) => entry.sessionId)).toContain(secondSession.sessionId); + expect(active.map((entry) => entry.sessionId)).not.toContain(firstSession.sessionId); + expect(firstError._tag).toBe("ServerAuthInvalidCredentialError"); + for (const stale of staleSessions) { + const error = yield* sessions.verify(stale.token).pipe(Effect.flip); + expect(error._tag).toBe("SessionTokenRevokedError"); + } + const pairedSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(paired.access_token), + ); + expect(pairedSession.subject).toBe("one-time-token"); + expect(active.map((entry) => entry.sessionId)).toContain(pairedSession.sessionId); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); + it.effect("keeps user-issued administrative pairing links manageable", () => 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 2d0f02274de9..b0406b6e6ecd 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -750,6 +750,9 @@ export const make = Effect.gen(function* () { ttl: Duration.hours(1), } : {}), + // Desktop restarts forget the previous bearer token. Replace + // its session, including stale entries left by older versions. + replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", client: { ...requestMetadata, ...(grant.label ? { label: grant.label } : {}), diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index aa3b2d199148..fa87c5ce4e84 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -4,6 +4,7 @@ import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -50,6 +51,7 @@ const repositoryFailure = new PersistenceSqlError({ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, { create: () => Effect.void, + createReplacingActive: () => Effect.succeed([]), getById: () => Effect.fail(repositoryFailure), listActive: () => Effect.succeed([]), revoke: () => Effect.fail(repositoryFailure), @@ -181,6 +183,78 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); + it.effect("atomically replaces active sessions with the same subject and method", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const browser = yield* sessions.issue({ + subject: "desktop-bootstrap", + method: "browser-session-cookie", + }); + const [firstBearer, secondBearer] = yield* Effect.all( + [ + sessions.issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + replaceActiveForSubjectAndMethod: true, + }), + sessions.issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + replaceActiveForSubjectAndMethod: true, + }), + ], + { concurrency: "unbounded" }, + ); + + const active = yield* sessions.listActive(); + const bearerVerification = yield* Effect.all([ + sessions.verify(firstBearer.token).pipe(Effect.option), + sessions.verify(secondBearer.token).pipe(Effect.option), + ]); + + expect(active).toHaveLength(2); + expect(active.find((entry) => entry.sessionId === browser.sessionId)).toBeDefined(); + expect( + active.filter( + (entry) => + entry.subject === "desktop-bootstrap" && entry.method === "bearer-access-token", + ), + ).toHaveLength(1); + expect(bearerVerification.filter(Option.isSome)).toHaveLength(1); + }).pipe(Effect.provide(makeSessionStoreLayer())), + ); + + it.effect("keeps the previous desktop session valid when replacement fails", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const previous = yield* sessions.issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + }); + yield* sql` + CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions + BEGIN + SELECT RAISE(ABORT, 'simulated insert failure'); + END + `; + + const error = yield* sessions + .issue({ + subject: "desktop-bootstrap", + method: "bearer-access-token", + replaceActiveForSubjectAndMethod: true, + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SessionCredentialIssueError"); + expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId); + expect((yield* sessions.listActive()).map((session) => session.sessionId)).toEqual([ + previous.sessionId, + ]); + }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), + ); + it.effect("rejects websocket tokens once the parent session has expired", () => 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 d4fbe445edf6..b315bdf87c7f 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -370,6 +370,11 @@ export class SessionStore extends Context.Service< readonly scopes?: ReadonlyArray; readonly client?: AuthClientMetadata; readonly proofKeyThumbprint?: string; + /** + * Atomically revoke active sessions with the same subject and method + * before storing this session. + */ + readonly replaceActiveForSubjectAndMethod?: boolean; }) => Effect.Effect; readonly verify: (token: string) => Effect.Effect; readonly issueWebSocketToken: ( @@ -647,24 +652,40 @@ export const make = Effect.gen(function* () { ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); - yield* authSessions - .create({ - sessionId, - subject: claims.sub, - scopes: claims.scopes, - method: claims.method, - client: { - label: client.label ?? null, - ipAddress: client.ipAddress ?? null, - userAgent: client.userAgent ?? null, - deviceType: client.deviceType, - os: client.os ?? null, - browser: client.browser ?? null, - }, - issuedAt, - expiresAt, - }) - .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); + const sessionRecord = { + sessionId, + subject: claims.sub, + scopes: claims.scopes, + method: claims.method, + client: { + label: client.label ?? null, + ipAddress: client.ipAddress ?? null, + userAgent: client.userAgent ?? null, + deviceType: client.deviceType, + os: client.os ?? null, + browser: client.browser ?? null, + }, + issuedAt, + expiresAt, + } satisfies AuthSessions.CreateAuthSessionInput; + const replacedSessionIds = yield* ( + input?.replaceActiveForSubjectAndMethod + ? authSessions.createReplacingActive({ session: sessionRecord, revokedAt: issuedAt }) + : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray)) + ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); + if (replacedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const replacedSessionId of replacedSessionIds) { + next.delete(replacedSessionId); + } + return next; + }); + yield* Effect.forEach(replacedSessionIds, emitRemoved, { + concurrency: "unbounded", + discard: true, + }); + } yield* emitUpsert( toAuthClientSession({ sessionId, diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 579d3a608190..b47f148f0761 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -55,6 +55,13 @@ export const CreateAuthSessionInput = Schema.Struct({ }); export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; +export const CreateReplacingActiveAuthSessionInput = Schema.Struct({ + session: CreateAuthSessionInput, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type CreateReplacingActiveAuthSessionInput = + typeof CreateReplacingActiveAuthSessionInput.Type; + export const GetAuthSessionByIdInput = Schema.Struct({ sessionId: AuthSessionId, }); @@ -96,6 +103,9 @@ export class AuthSessionRepository extends Context.Service< readonly create: ( input: CreateAuthSessionInput, ) => Effect.Effect; + readonly createReplacingActive: ( + input: CreateReplacingActiveAuthSessionInput, + ) => Effect.Effect, AuthSessionRepositoryError>; readonly getById: ( input: GetAuthSessionByIdInput, ) => Effect.Effect, AuthSessionRepositoryError>; @@ -254,6 +264,21 @@ export const make = Effect.gen(function* () { `, }); + const revokeActiveSessionsForReplacement = SqlSchema.findAll({ + Request: CreateReplacingActiveAuthSessionInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ session, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE subject = ${session.subject} + AND method = ${session.method} + AND revoked_at IS NULL + AND expires_at > ${revokedAt} + RETURNING session_id AS "sessionId" + `, + }); + const listActiveSessionRows = SqlSchema.findAll({ Request: ListActiveAuthSessionsInput, Result: AuthSessionRawDbRow, @@ -343,6 +368,29 @@ export const make = Effect.gen(function* () { ), ); + const createReplacingActive: AuthSessionRepository["Service"]["createReplacingActive"] = ( + input, + ) => + sql + .withTransaction( + revokeActiveSessionsForReplacement(input).pipe( + Effect.flatMap((revokedRows) => + createSessionRow(input.session).pipe( + Effect.as(revokedRows.map((row) => row.sessionId)), + ), + ), + ), + ) + .pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.createReplacingActive:query", + "AuthSessionRepository.createReplacingActive:encodeRequest", + { sessionId: input.session.sessionId }, + ), + ), + ); + const getById: AuthSessionRepository["Service"]["getById"] = (input) => getSessionRowById(input).pipe( Effect.mapError( @@ -442,6 +490,7 @@ export const make = Effect.gen(function* () { return { create, + createReplacingActive, getById, listActive, revoke, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 85afe00cb52a..07aee5e861d3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1905,6 +1905,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("replaces the local desktop credential on repeated bootstrap exchanges", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const first = yield* exchangeAccessToken(); + const second = yield* exchangeAccessToken(); + const third = yield* exchangeAccessToken(); + assert.equal(first.response.status, 200); + assert.equal(second.response.status, 200); + assert.equal(third.response.status, 200); + + const clientsResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { authorization: `Bearer ${third.body.access_token}` }, + }); + const clients = (yield* clientsResponse.json) as ReadonlyArray<{ + readonly current: boolean; + readonly subject: string; + }>; + assert.equal(clientsResponse.status, 200); + assert.equal(clients.length, 1); + assert.equal(clients[0]?.current, true); + assert.equal(clients[0]?.subject, "desktop-bootstrap"); + + for (const previous of [first, second]) { + const response = yield* HttpClient.get("/api/auth/session", { + headers: { authorization: `Bearer ${previous.body.access_token}` }, + }); + const state = (yield* response.json) as { readonly authenticated: boolean }; + assert.equal(state.authenticated, false); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("persists token exchange client display metadata for authorized-client listings", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 98b3df0a0dc8..068fcfa68a90 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -121,6 +121,12 @@ Sessions issued from a plain bearer exchange use the store's only to DPoP-bound exchanges, where the token is additionally constrained by a proof key. See `SessionStore.ts` and `EnvironmentAuth.ts`. +The reusable `desktop-bootstrap` grant replaces active sessions with the same +subject and authentication method. Revocation and insertion share one database +transaction, so a failed insertion preserves the previous credential. This also +removes stale local desktop entries from earlier launches. Browser-cookie sessions +and sessions issued through pairing links are not replaced. + Requested scopes must be a subset of the one-time bootstrap credential grant. An ordinary paired client therefore cannot exchange its grant for `access:read`, `access:write`, or `relay:write`. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 50a07b50fd2b..b2b540a83e2c 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -80,6 +80,10 @@ and expiry, and can revoke it if they have access management permission. The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks. +After an app restart, the desktop app replaces its previous +local credential. Old local desktop entries are removed from **Authorized clients** +automatically. Paired phones, browsers, and remote desktop clients keep their access. + When no user default is saved, the app uses the built-in LAN endpoint for pairing links when available. You can set another endpoint as the default from the expanded endpoint list. From d487dfbf46be344e818725be70ee04be2436bfb4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 04:25:32 -0700 Subject: [PATCH 003/262] fix(web): resume Antigravity threads without repeated sign-in (#9647) Allow Antigravity threads to resume while saved Google sign-in is unchecked after a server restart. Keep confirmed authentication failures and installation errors visible. Validated with 136 focused tests, web typecheck, targeted lint, and CI. Browser verification was omitted at the maintainer's request. Created with GPT-6 Astra (preview) in Codex. --- .../web/src/components/ChatView.logic.test.ts | 15 +++++--- apps/web/src/components/ChatView.logic.ts | 9 +++-- .../chat/ProviderStatusBanner.test.tsx | 36 +++++++++++++++++++ .../components/chat/ProviderStatusBanner.tsx | 19 +++++++--- docs/user/providers-antigravity.md | 12 ++++--- 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8e9c80641f2b..c8afd10d3725 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,5 @@ import { + ANTIGRAVITY_DEFAULT_MODEL, CheckpointRef, EnvironmentId, MessageId, @@ -793,14 +794,20 @@ describe("resolveComposerProviderSelection", () => { ); }); - it("blocks sends until Antigravity confirms authentication", () => { + it("lets Antigravity check saved credentials when resuming after a restart", () => { const provider = entry("antigravity", "google_work", { + status: "warning", auth: { status: "unknown" }, - models: catalogModels, + models: [], }).snapshot; - expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe( - "Sign in to Antigravity in provider settings before sending.", + expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull(); + expect(getAntigravitySendBlockReason(provider, ANTIGRAVITY_DEFAULT_MODEL)).toBeNull(); + expect( + getAntigravitySendBlockReason({ ...provider, models: catalogModels }, "gemini-pro"), + ).toBeNull(); + expect(getAntigravitySendBlockReason(provider, "")).toBe( + "Choose an Antigravity model before sending.", ); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1a6b1b775f41..4a0b9f576103 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -421,14 +421,17 @@ export function getAntigravitySendBlockReason( if (!provider.installed) { return "Install Antigravity in provider settings before sending."; } - if (provider.auth.status !== "authenticated") { + if (provider.auth.status === "unauthenticated") { return "Sign in to Antigravity in provider settings before sending."; } + const slug = model.trim(); + if (slug.length === 0) return "Choose an Antigravity model before sending."; + // A restart clears the account status and catalog. Session startup checks + // saved credentials and validates the model before sending the prompt. + if (provider.auth.status === "unknown") return null; if (provider.models.length === 0) { return "Refresh Antigravity models in provider settings before sending."; } - const slug = model.trim(); - if (slug.length === 0) return "Choose an Antigravity model before sending."; // A saved model that left the catalog is kept in the picker as unavailable // so the user sees what the thread used. The server rejects it at turn // start, so block here unless the provider is in an error state, where a diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx index f27cda19d957..e51383bc69fe 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx @@ -28,6 +28,42 @@ function warningProvider(): ServerProvider { } describe("ProviderStatusBanner", () => { + it("waits for an Antigravity auth result before showing a sign-in warning", () => { + const status: ServerProvider = { + ...warningProvider(), + instanceId: ProviderInstanceId.make("google_work"), + driver: ProviderDriverKind.make("antigravity"), + auth: { status: "unknown" }, + message: "Antigravity is installed. Google account access is not checked yet.", + }; + + expect(shouldShowProviderStatusBanner(status, null)).toBe(false); + expect( + shouldShowProviderStatusBanner( + { + ...status, + auth: { status: "unauthenticated" }, + message: "Sign in with Google to use Antigravity.", + }, + null, + ), + ).toBe(true); + }); + + it("shows Antigravity installation and startup failures before auth is checked", () => { + const status: ServerProvider = { + ...warningProvider(), + driver: ProviderDriverKind.make("antigravity"), + auth: { status: "unknown" }, + }; + + expect(shouldShowProviderStatusBanner({ ...status, installed: false }, null)).toBe(true); + expect(shouldShowProviderStatusBanner({ ...status, status: "error" }, null)).toBe(true); + expect( + shouldShowProviderStatusBanner({ ...status, driver: ProviderDriverKind.make("codex") }, null), + ).toBe(true); + }); + it("stays hidden after its current warning is dismissed", () => { const status = warningProvider(); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 2ae4bc8b58d4..12f9dc04f26a 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -7,9 +7,20 @@ import { formatProviderDriverKindLabel } from "../../providerModels"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { - return !status || status.status === "ready" || status.status === "disabled" - ? null - : [status.instanceId, status.status, status.auth.status, status.message ?? ""].join("\u0000"); + if (!status || status.status === "ready" || status.status === "disabled") return null; + // Antigravity checks saved credentials when a session starts. Its local + // health check leaves auth unknown after a restart, which is not a failure. + if ( + status.driver === "antigravity" && + status.installed && + status.status === "warning" && + status.auth.status === "unknown" + ) { + return null; + } + return [status.instanceId, status.status, status.auth.status, status.message ?? ""].join( + "\u0000", + ); } export function shouldShowProviderStatusBanner( @@ -59,7 +70,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; status: ServerProvider | null; }) { - if (!status || status.status === "ready" || status.status === "disabled") { + if (!status || getProviderStatusBannerKey(status) === null) { return null; } diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index bd84ced3a395..24fe8c05780e 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -179,10 +179,14 @@ paid-plan tier or remaining subscription quota. See Google's [Antigravity plans] [personal Google sign-in guide][google-setup]. After an environment restarts, Google sign-in can show as not checked until an authenticated -session succeeds. To check account access and reload models, use **Refresh provider status** -in web or desktop provider settings, or **Refresh models** in the mobile model picker. Refresh -uses saved Google sign-in and does not open a login page. If sign-in is required, use the -provider's setup controls. Automatic status checks verify the installation only. +session succeeds. You can continue an existing thread. Antigravity checks saved Google sign-in +when the session starts. An unchecked status does not require signing in again. + +To check account access and reload models on web or desktop, open **Settings** > **Providers** +and select the circular arrow beside **Checked** at the top of the page. Its tooltip says +**Refresh provider status**. On mobile, use **Refresh models** in the model picker. +Refresh uses saved Google sign-in and does not open a login page. If sign-in is required, +use the provider's setup controls. Automatic status checks verify the installation only. The packaged runtime can be slow to start, especially on Windows. Health checks, model refresh, and sign-out each allow up to 90 seconds before reporting a timeout. From d5b94100863057fb4629f9ad4a35753d16917924 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:41:57 +0200 Subject: [PATCH 004/262] feat(mobile): paste the phone clipboard into the terminal (#9199) Co-authored-by: Jake Leventhal Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../terminal/ThreadTerminalRouteScreen.tsx | 138 ++++++++++++------ .../features/terminal/terminalInput.test.ts | 125 ++++++++++++++++ .../src/features/terminal/terminalInput.ts | 108 ++++++++++++++ .../features/terminal/terminalMenu.test.ts | 1 + .../features/terminal/terminalPaste.test.ts | 82 +++++++++++ .../src/features/terminal/terminalPaste.ts | 60 ++++++++ .../src/state/terminalSession.test.ts | 38 +++++ .../src/state/terminalSession.ts | 16 +- 8 files changed, 520 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/src/features/terminal/terminalInput.test.ts create mode 100644 apps/mobile/src/features/terminal/terminalInput.ts create mode 100644 apps/mobile/src/features/terminal/terminalPaste.test.ts create mode 100644 apps/mobile/src/features/terminal/terminalPaste.ts diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index a51e084efc9e..351082580d63 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -6,6 +6,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import * as Schema from "effect/Schema"; import { KeyboardController, KeyboardEvents, @@ -27,6 +29,7 @@ import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; +import { useServerConfigs } from "../../state/entities"; import { useWorkspaceState } from "../../state/workspace"; import { MAX_TERMINAL_FONT_SIZE, @@ -65,6 +68,13 @@ import { resolveTerminalSessionLabel, type TerminalMenuSession, } from "./terminalMenu"; +import { + hostPlatformFromOs, + resolveModifiedTerminalInput, + type HostPlatform, + type PendingModifier, +} from "./terminalInput"; +import { createTerminalPasteSession } from "./terminalPaste"; import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; const DEFAULT_TERMINAL_COLS = 80; @@ -72,12 +82,19 @@ const DEFAULT_TERMINAL_ROWS = 24; const TERMINAL_ACCESSORY_HEIGHT = 52; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; -type PendingModifier = "ctrl" | "meta"; -type HostPlatform = "mac" | "linux" | "windows" | "unknown"; +class TerminalClipboardReadError extends Schema.TaggedErrorClass()( + "TerminalClipboardReadError", + { terminalId: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Failed to read the clipboard for a paste into terminal ${this.terminalId}.`; + } +} type TerminalToolbarAction = | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string } | { readonly kind: "clear"; readonly key: string; readonly label: string } + | { readonly kind: "paste"; readonly key: string; readonly label: string } | { readonly kind: "modifier"; readonly key: string; @@ -114,28 +131,6 @@ function inferHostPlatform(environmentLabel: string | null): HostPlatform { return "unknown"; } -function applyCtrlModifier(input: string): string { - const firstCharacter = input[0]; - if (!firstCharacter) { - return input; - } - - const lowerCharacter = firstCharacter.toLowerCase(); - if (lowerCharacter >= "a" && lowerCharacter <= "z") { - return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); - } - - if (firstCharacter === "@") return "\u0000"; - if (firstCharacter === "[") return "\u001b"; - if (firstCharacter === "\\") return "\u001c"; - if (firstCharacter === "]") return "\u001d"; - if (firstCharacter === "^") return "\u001e"; - if (firstCharacter === "_") return "\u001f"; - if (firstCharacter === "?") return "\u007f"; - - return input; -} - function pickRunningTerminalSessionForBootstrap( sessions: ReadonlyArray, ): KnownTerminalSession | null { @@ -462,9 +457,17 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }); }, [terminal.buffer, terminal.buffer.length, terminalKey]); const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; + const serverConfigs = useServerConfigs(); + const hostOs = + routeEnvironmentId === null + ? null + : (serverConfigs.get(routeEnvironmentId)?.environment.platform.os ?? null); + // The descriptor is authoritative; the label is only a hint until it arrives. const hostPlatform = useMemo( - () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), - [selectedEnvironmentConnection?.environmentLabel], + () => + hostPlatformFromOs(hostOs) ?? + inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), + [hostOs, selectedEnvironmentConnection?.environmentLabel], ); const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); @@ -488,6 +491,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { kind: "send", key: "esc", label: "esc", data: "\u001b" }, ...modifierActions, { kind: "send", key: "tab", label: "tab", data: "\t" }, + { kind: "paste", key: "paste", label: "paste" }, { kind: "clear", key: "clear", label: "clear" }, { kind: "send", key: "up", label: "โ†‘", data: "\u001b[A" }, { kind: "send", key: "down", label: "โ†“", data: "\u001b[B" }, @@ -693,13 +697,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) setHasMeasuredSurface(true); }, [routeEnvironmentId, routeThreadId, terminalId]); + /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */ const writeInput = useCallback( - (data: string) => { + async (data: string): Promise => { if (!selectedThread || !isRunning) { - return; + return false; } - void writeTerminal({ + const result = await writeTerminal({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, @@ -707,27 +712,67 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) data, }, }); + return result._tag === "Success"; }, [isRunning, selectedThread, terminalId, writeTerminal], ); + const pasteSessionRef = useRef | null>(null); + if (pasteSessionRef.current === null) { + pasteSessionRef.current = createTerminalPasteSession(); + } + const pasteSession = pasteSessionRef.current; + + // Drop delayed clipboard reads whenever the route or attached pty changes. + useEffect(() => { + pasteSession.reset(isRunning); + return () => { + pasteSession.reset(false); + }; + }, [isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]); + + const pasteFromClipboard = useCallback(async () => { + await pasteSession.paste({ + readText: Clipboard.getStringAsync, + write: writeInput, + onReadError: (cause) => { + console.error(new TerminalClipboardReadError({ terminalId, cause })); + }, + }); + }, [pasteSession, terminalId, writeInput]); + + /** Sends a key through the armed toolbar modifier, if any, and disarms it. */ + const writeModifiedInput = useCallback( + (data: string) => { + if (pendingModifier === null) { + void writeInput(data); + return; + } + + setPendingModifierState({ terminalId, value: null }); + const resolved = resolveModifiedTerminalInput({ + data, + modifier: pendingModifier, + hostPlatform, + }); + if (resolved.kind === "paste") { + void pasteFromClipboard(); + return; + } + void writeInput(resolved.data); + }, + [hostPlatform, pasteFromClipboard, pendingModifier, terminalId, writeInput], + ); + const handleInput = useCallback( (data: string) => { if (data.length === 0) { return; } - if (pendingModifier === "ctrl") { - setPendingModifierState({ terminalId, value: null }); - writeInput(applyCtrlModifier(data)); - } else if (pendingModifier === "meta") { - setPendingModifierState({ terminalId, value: null }); - writeInput(`\u001b${data}`); - } else { - writeInput(data); - } + writeModifiedInput(data); }, - [pendingModifier, terminalId, writeInput], + [writeModifiedInput], ); const handleResize = useCallback( @@ -1021,16 +1066,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) return; } - setPendingModifierState({ terminalId, value: null }); - if (pendingModifier === "ctrl") { - writeInput(applyCtrlModifier(action.data)); - } else if (pendingModifier === "meta") { - writeInput(`\u001b${action.data}`); - } else { - writeInput(action.data); + if (action.kind === "paste") { + setPendingModifierState({ terminalId, value: null }); + void pasteFromClipboard(); + return; } + + writeModifiedInput(action.data); }, - [handleClearTerminal, pendingModifier, terminalId, writeInput], + [handleClearTerminal, pasteFromClipboard, terminalId, writeModifiedInput], ); const handleDismissKeyboard = useCallback(() => { diff --git a/apps/mobile/src/features/terminal/terminalInput.test.ts b/apps/mobile/src/features/terminal/terminalInput.test.ts new file mode 100644 index 000000000000..aebc00649cec --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalInput.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applyCtrlModifier, + chunkTerminalWrite, + encodeTerminalPaste, + hostPlatformFromOs, + resolveModifiedTerminalInput, + TERMINAL_WRITE_MAX_LENGTH, +} from "./terminalInput"; + +const byte = (code: number) => String.fromCharCode(code); +const ESC = byte(0x1b); +const CTRL_C = byte(0x03); +const CTRL_V = byte(0x16); + +describe("applyCtrlModifier", () => { + it("maps letters to control bytes regardless of case", () => { + expect(applyCtrlModifier("c")).toBe(CTRL_C); + expect(applyCtrlModifier("C")).toBe(CTRL_C); + expect(applyCtrlModifier("z")).toBe(byte(0x1a)); + }); + + it("maps the punctuation control keys and leaves the rest untouched", () => { + expect(applyCtrlModifier("[")).toBe(ESC); + expect(applyCtrlModifier("?")).toBe(byte(0x7f)); + expect(applyCtrlModifier("1")).toBe("1"); + expect(applyCtrlModifier("")).toBe(""); + }); +}); + +describe("resolveModifiedTerminalInput", () => { + it("pastes on ctrl+v for windows, linux, and unknown hosts", () => { + for (const hostPlatform of ["windows", "linux", "unknown"] as const) { + expect(resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform })).toEqual({ + kind: "paste", + }); + expect(resolveModifiedTerminalInput({ data: "V", modifier: "ctrl", hostPlatform })).toEqual({ + kind: "paste", + }); + } + }); + + it("keeps alt+v as a meta chord on non-mac hosts", () => { + expect( + resolveModifiedTerminalInput({ data: "v", modifier: "meta", hostPlatform: "windows" }), + ).toEqual({ kind: "write", data: `${ESC}v` }); + }); + + it("pastes on cmd+v and forwards raw ctrl+v on mac hosts", () => { + expect( + resolveModifiedTerminalInput({ data: "v", modifier: "meta", hostPlatform: "mac" }), + ).toEqual({ kind: "paste" }); + expect( + resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform: "mac" }), + ).toEqual({ kind: "write", data: CTRL_V }); + }); + + it("still encodes every other modified key", () => { + expect( + resolveModifiedTerminalInput({ data: "c", modifier: "ctrl", hostPlatform: "windows" }), + ).toEqual({ kind: "write", data: CTRL_C }); + expect( + resolveModifiedTerminalInput({ data: "[A", modifier: "meta", hostPlatform: "linux" }), + ).toEqual({ kind: "write", data: `${ESC}[A` }); + }); +}); + +describe("encodeTerminalPaste", () => { + it("passes single-line text through unchanged", () => { + expect(encodeTerminalPaste("git switch -c fix/paste")).toBe("git switch -c fix/paste"); + expect(encodeTerminalPaste("")).toBe(""); + }); + + it("turns LF and CRLF line breaks into a single carriage return each", () => { + expect(encodeTerminalPaste("one\ntwo\r\nthree\n")).toBe("one\rtwo\rthree\r"); + }); + + it("replaces unsafe control bytes with spaces but keeps tabs", () => { + expect(encodeTerminalPaste(`a${byte(0)}b${ESC}c${byte(0x7f)}d\te`)).toBe("a b c d\te"); + }); + + it("never lets a bracketed-paste end marker reach the shell", () => { + expect(encodeTerminalPaste(`safe${ESC}[201~; rm -rf /\n`)).toBe("safe [201~; rm -rf /\r"); + }); +}); + +describe("chunkTerminalWrite", () => { + it("leaves writes within the wire limit whole", () => { + expect(chunkTerminalWrite("")).toEqual([]); + expect(chunkTerminalWrite("ls")).toEqual(["ls"]); + expect(chunkTerminalWrite("x".repeat(TERMINAL_WRITE_MAX_LENGTH))).toHaveLength(1); + }); + + it("splits oversized writes so every chunk fits the contract", () => { + const chunks = chunkTerminalWrite("y".repeat(TERMINAL_WRITE_MAX_LENGTH * 2 + 5)); + expect(chunks.map((chunk) => chunk.length)).toEqual([ + TERMINAL_WRITE_MAX_LENGTH, + TERMINAL_WRITE_MAX_LENGTH, + 5, + ]); + expect(chunks.join("")).toHaveLength(TERMINAL_WRITE_MAX_LENGTH * 2 + 5); + }); + + it("does not cut a surrogate pair in half at the boundary", () => { + const data = `${"z".repeat(TERMINAL_WRITE_MAX_LENGTH - 1)}๐Ÿ˜€tail`; + const chunks = chunkTerminalWrite(data); + expect(chunks[0]).toHaveLength(TERMINAL_WRITE_MAX_LENGTH - 1); + expect(chunks[1]).toBe("๐Ÿ˜€tail"); + expect(chunks.join("")).toBe(data); + }); +}); + +describe("hostPlatformFromOs", () => { + it("maps the descriptor os onto the toolbar layout", () => { + expect(hostPlatformFromOs("darwin")).toBe("mac"); + expect(hostPlatformFromOs("windows")).toBe("windows"); + expect(hostPlatformFromOs("linux")).toBe("linux"); + }); + + it("defers to the caller when the os is unknown or not loaded yet", () => { + expect(hostPlatformFromOs("unknown")).toBeNull(); + expect(hostPlatformFromOs(null)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalInput.ts b/apps/mobile/src/features/terminal/terminalInput.ts new file mode 100644 index 000000000000..d7cd60dd7566 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalInput.ts @@ -0,0 +1,108 @@ +import type { ExecutionEnvironmentPlatformOs } from "@t3tools/contracts"; + +export type PendingModifier = "ctrl" | "meta"; +export type HostPlatform = "mac" | "linux" | "windows" | "unknown"; + +/** Upper bound of `TerminalWriteInput.data`; longer writes are rejected by the server. */ +export const TERMINAL_WRITE_MAX_LENGTH = 65_536; + +export type ModifiedTerminalInput = + | { readonly kind: "write"; readonly data: string } + | { readonly kind: "paste" }; + +// C0 controls other than tab, LF, and CR, plus DEL. +// eslint-disable-next-line no-control-regex -- Pasted text must not carry raw terminal controls. +const UNSAFE_PASTE_BYTES = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g; + +/** + * Encodes a key pressed while the toolbar's one-shot ctrl modifier is armed + * into the control byte a terminal expects. + */ +export function applyCtrlModifier(input: string): string { + const firstCharacter = input[0]; + if (!firstCharacter) { + return input; + } + + const lowerCharacter = firstCharacter.toLowerCase(); + if (lowerCharacter >= "a" && lowerCharacter <= "z") { + return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); + } + + if (firstCharacter === "@") return "\u0000"; + if (firstCharacter === "[") return "\u001b"; + if (firstCharacter === "\\") return "\u001c"; + if (firstCharacter === "]") return "\u001d"; + if (firstCharacter === "^") return "\u001e"; + if (firstCharacter === "_") return "\u001f"; + if (firstCharacter === "?") return "\u007f"; + + return input; +} + +/** + * Resolves what a keypress means once a toolbar modifier is armed. The host's + * paste chord (cmd+v on a macOS host, ctrl+v elsewhere) pastes the device + * clipboard instead of reaching the remote shell as a raw control byte, which + * matches what the web terminal does with the same chord. Forwarding the byte + * is never what a phone user means: PowerShell binds ctrl+v to paste from the + * host machine's clipboard, so the shell inserts whatever the desktop last + * copied rather than the text on the phone. + */ +export function resolveModifiedTerminalInput(input: { + readonly data: string; + readonly modifier: PendingModifier; + readonly hostPlatform: HostPlatform; +}): ModifiedTerminalInput { + const pasteModifier: PendingModifier = input.hostPlatform === "mac" ? "meta" : "ctrl"; + if (input.modifier === pasteModifier && input.data.toLowerCase() === "v") { + return { kind: "paste" }; + } + + return { + kind: "write", + data: input.modifier === "ctrl" ? applyCtrlModifier(input.data) : `\u001b${input.data}`, + }; +} + +/** + * Encodes clipboard text for the remote pty the way the web terminal does when + * bracketed paste is off: unsafe control bytes become spaces (which also + * defuses an embedded bracketed-paste end marker, since its ESC goes too) and + * line breaks become carriage returns, since a bare LF is Ctrl+J to a raw-mode + * TUI. The native mobile surface does not expose DECSET 2004, so mobile never + * wraps a paste in bracketed-paste markers. + */ +export function encodeTerminalPaste(text: string): string { + return text.replace(UNSAFE_PASTE_BYTES, " ").replace(/\r\n|\n/g, "\r"); +} + +/** + * Splits terminal input into writes the wire contract accepts, never cutting + * through a surrogate pair so every chunk stays valid UTF-16. + */ +export function chunkTerminalWrite(data: string): ReadonlyArray { + const chunks: string[] = []; + let start = 0; + while (start < data.length) { + let end = Math.min(start + TERMINAL_WRITE_MAX_LENGTH, data.length); + const last = data.charCodeAt(end - 1); + if (end < data.length && last >= 0xd800 && last <= 0xdbff) { + end -= 1; + } + chunks.push(data.slice(start, end)); + start = end; + } + return chunks; +} + +/** + * Maps the OS reported by the environment descriptor onto the toolbar's host + * layout. Returns null for "unknown" so callers can fall back to a weaker signal. + */ +export function hostPlatformFromOs(os: ExecutionEnvironmentPlatformOs | null): HostPlatform | null { + if (os === "darwin") return "mac"; + if (os === "linux") return "linux"; + if (os === "windows") return "windows"; + return null; +} diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 966312270951..bbb16a081454 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -61,6 +61,7 @@ function makeKnownSession(input: { hasRunningSubprocess: false, updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", version: 1, + lifecycleVersion: 1, }, }; } diff --git a/apps/mobile/src/features/terminal/terminalPaste.test.ts b/apps/mobile/src/features/terminal/terminalPaste.test.ts new file mode 100644 index 000000000000..ec9bd9d74d93 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalPaste.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { TERMINAL_WRITE_MAX_LENGTH } from "./terminalInput"; +import { createTerminalPasteSession } from "./terminalPaste"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("terminal paste session", () => { + it("drops a clipboard read when the pty restarts in place", async () => { + const session = createTerminalPasteSession(); + session.reset(true); + const clipboardRead = deferred(); + const writes: string[] = []; + + const paste = session.paste({ + readText: () => clipboardRead.promise, + write: async (data) => { + writes.push(data); + return true; + }, + onReadError: () => undefined, + }); + + session.reset(true); + clipboardRead.resolve("stale"); + await paste; + + expect(writes).toEqual([]); + }); + + it("never overlaps writes from rapid paste requests", async () => { + const session = createTerminalPasteSession(); + session.reset(true); + + const firstWrite = deferred(); + const firstWriteStarted = deferred(); + const writes: string[] = []; + let activeWrites = 0; + let maximumActiveWrites = 0; + const write = async (data: string) => { + writes.push(data); + activeWrites += 1; + maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites); + if (writes.length === 1) { + firstWriteStarted.resolve(); + await firstWrite.promise; + } + activeWrites -= 1; + return true; + }; + + const olderPaste = session.paste({ + readText: async () => "a".repeat(TERMINAL_WRITE_MAX_LENGTH + 1), + write, + onReadError: () => undefined, + }); + await firstWriteStarted.promise; + + const newerPaste = session.paste({ + readText: async () => "newer", + write, + onReadError: () => undefined, + }); + await Promise.resolve(); + + expect(writes.map((chunk) => chunk.length)).toEqual([TERMINAL_WRITE_MAX_LENGTH]); + expect(maximumActiveWrites).toBe(1); + + firstWrite.resolve(true); + await Promise.all([olderPaste, newerPaste]); + + expect(writes.map((chunk) => chunk.length)).toEqual([TERMINAL_WRITE_MAX_LENGTH, 5]); + expect(writes[1]).toBe("newer"); + expect(maximumActiveWrites).toBe(1); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalPaste.ts b/apps/mobile/src/features/terminal/terminalPaste.ts new file mode 100644 index 000000000000..3368cd3a5bb7 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalPaste.ts @@ -0,0 +1,60 @@ +import { chunkTerminalWrite, encodeTerminalPaste } from "./terminalInput"; + +interface TerminalPasteInput { + readonly readText: () => Promise; + readonly write: (data: string) => Promise; + readonly onReadError: (cause: unknown) => void; +} + +export interface TerminalPasteSession { + readonly reset: (active: boolean) => void; + readonly paste: (input: TerminalPasteInput) => Promise; +} + +/** Coordinates clipboard reads and writes for the currently attached pty. */ +export function createTerminalPasteSession(): TerminalPasteSession { + let liveTarget: object | null = null; + let latestRequest = 0; + let writeTail: Promise = Promise.resolve(); + + return { + reset(active) { + liveTarget = active ? {} : null; + }, + + async paste({ readText, write, onReadError }) { + const target = liveTarget; + if (target === null) { + return; + } + const request = ++latestRequest; + const isCurrent = () => liveTarget === target && latestRequest === request; + + let text: string; + try { + text = await readText(); + } catch (cause) { + onReadError(cause); + return; + } + + if (!isCurrent()) { + return; + } + + const writePaste = async () => { + for (const chunk of chunkTerminalWrite(encodeTerminalPaste(text))) { + if (!isCurrent() || !(await write(chunk))) { + return; + } + } + }; + const queuedWrite = writeTail.then(writePaste, writePaste); + writeTail = queuedWrite.then( + () => undefined, + () => undefined, + ); + await queuedWrite; + }, + }; +} diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index 85c57592d118..2f3e3777a965 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -133,6 +133,44 @@ describe("terminal session reducers", () => { }); }); + it("does not advance the lifecycle for the initial attach snapshot", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + + expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 }); + }); + + it("advances the lifecycle for a live started snapshot", () => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const started = applyTerminalAttachStreamEvent(initial, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, pid: 456 }, + }); + + expect(started).toMatchObject({ status: "running", lifecycleVersion: 1 }); + }); + + it("advances the lifecycle when a running terminal restarts in place", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const restarted = applyTerminalAttachStreamEvent(snapshot, { + type: "restarted", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + snapshot: { ...BASE_SNAPSHOT, pid: 456 }, + }); + + expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 }); + expect(restarted).toMatchObject({ status: "running", lifecycleVersion: 1 }); + }); + it("reduces terminal metadata snapshots, upserts, and removals", () => { const initial = applyTerminalMetadataStreamEvent([], { type: "snapshot", diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index ee444e36db41..b4508d387046 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -15,6 +15,7 @@ export interface TerminalSessionState { readonly hasRunningSubprocess: boolean; readonly updatedAt: string | null; readonly version: number; + readonly lifecycleVersion: number; } export interface TerminalBufferState { @@ -23,6 +24,7 @@ export interface TerminalBufferState { readonly error: string | null; readonly updatedAt: string | null; readonly version: number; + readonly lifecycleVersion: number; } export interface KnownTerminalSessionTarget { @@ -50,6 +52,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ error: null, updatedAt: null, version: 0, + lifecycleVersion: 0, }); export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ @@ -60,6 +63,7 @@ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze( hasRunningSubprocess: false, updatedAt: null, version: 0, + lifecycleVersion: 0, }); export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; @@ -98,6 +102,7 @@ export function terminalBufferStateFromSnapshot( error: null, updatedAt: snapshot.updatedAt, version: 1, + lifecycleVersion: 0, }; } @@ -119,6 +124,7 @@ export function combineTerminalSessionState( hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), version: buffer.version, + lifecycleVersion: buffer.lifecycleVersion, }; } @@ -129,8 +135,16 @@ export function applyTerminalAttachStreamEvent( ): TerminalBufferState { switch (event.type) { case "snapshot": + return { + ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes), + lifecycleVersion: + current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1, + }; case "restarted": - return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); + return { + ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes), + lifecycleVersion: current.lifecycleVersion + 1, + }; case "output": return { ...current, From f0347322441f3b8e473a8d13ea7006cbcb4fb761 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 05:30:51 -0700 Subject: [PATCH 005/262] feat(web): show which sidebar threads hold an unsent draft (#9658) Co-authored-by: Claude Fable 5.1 --- apps/web/src/components/Sidebar.tsx | 76 +++++++++++++++++++++---- apps/web/src/composerDraftStore.test.ts | 26 +++++++++ apps/web/src/composerDraftStore.ts | 11 ++++ docs/user/thread-sidebar.md | 4 ++ 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d0ededf6ed83..03672d155a37 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -203,6 +203,7 @@ import { composerDraftHasUserContent, DraftId, useComposerDraftStore, + useThreadHasUnsentDraft, type ComposerThreadDraftState, type DraftSessionState, } from "../composerDraftStore"; @@ -487,6 +488,11 @@ function SortablePinnedThreadRow(props: { return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } +// Unsent work shares one look: the new-thread draft rows and thread rows +// with unsent composer text both use this tint and pen so they read alike. +const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]"; +const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80"; + // One unsent draft session the user has invested content in. Two lines, // nothing else: project name, then the typed prompt. All the draft's // settings (model, env mode, branch, worktree) still travel with it โ€” @@ -552,19 +558,14 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { data-testid="sidebar-draft-row" className={cn( "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground outline-none select-none", - props.isActive - ? "bg-sidebar-row-active" - : "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]", + props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName, )} onClick={handleActivate} onKeyDown={handleKeyDown} >
- + store.clearComposerContent); + const handleDiscardDraftClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + releaseComposerDraftUploads(threadRef); + clearComposerContent(threadRef); + }, + [clearComposerContent, threadRef], + ); const gitCwd = thread.worktreePath ?? props.projectCwd; const linkedPullRequestStatus = useLinkedThreadPullRequest( @@ -1163,9 +1177,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected ? "bg-sidebar-row-selected text-sidebar-foreground" - : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" - : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + : hasUnsentDraft + ? cn(draftSurfaceClassName, "text-sidebar-foreground") + : shouldRecede + ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" + : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", isInFlight && !props.isActive && !isSelected && @@ -1251,6 +1267,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; + // Same pen the new-thread draft rows lead with, so both kinds of unsent + // work read the same way in the list. + const draftIndicator = hasUnsentDraft ? ( + + + } + > + + + Unsent draft + + ) : null; const pinIndicator = props.isPinned ? ( props.pinningSupported ? ( @@ -1318,6 +1353,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { className="size-4" /> + {draftIndicator} {title} {pinIndicator} {terminalStatusIcon} @@ -1465,6 +1501,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { >
+ {draftIndicator} - {props.settlementSupported || showSnoozeButton ? ( + {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( + {hasUnsentDraft ? ( + + + } + > + + + Discard draft + + ) : null} {showSnoozeButton ? ( { }); }); +describe("composerDraftStore unsent draft marker", () => { + const threadId = ThreadId.make("thread-unsent-marker"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("reports content for typed text and clears when the composer is emptied", () => { + const hasDraft = () => + composerDraftHasUserContent(useComposerDraftStore.getState().getComposerDraft(threadRef)); + + expect(hasDraft()).toBe(false); + + useComposerDraftStore.getState().setPrompt(threadRef, " "); + expect(hasDraft()).toBe(false); + + useComposerDraftStore.getState().setPrompt(threadRef, "follow up on the relay case"); + expect(hasDraft()).toBe(true); + + useComposerDraftStore.getState().clearComposerContent(threadRef); + expect(hasDraft()).toBe(false); + }); +}); + describe("composerDraftStore file attachments", () => { const threadId = ThreadId.make("thread-files"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2e8b8ae76779..4420f61837b2 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4009,6 +4009,17 @@ export function useComposerThreadDraft(threadRef: ComposerThreadTarget): Compose }); } +/** + * True when a real thread's composer holds unsent user content. Selects a + * boolean so the sidebar row that reads it re-renders only when the draft + * appears or disappears, not on every keystroke. + */ +export function useThreadHasUnsentDraft(threadRef: ScopedThreadRef): boolean { + return useComposerDraftStore((state) => + composerDraftHasUserContent(getComposerDraftState(state, threadRef)), + ); +} + export function useComposerDraftModelState( threadRef: ComposerThreadTarget, ): ComposerDraftModelState { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 204678eb1d2f..3d6311641d59 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -36,6 +36,10 @@ by older clients on one device no longer control this behavior. When you un-settle a thread, it returns to the top of the active list so you can find it right away. Its timestamps do not change. Other threads keep their positions. +A thread whose composer holds unsent text or attachments shows an amber tint and a pen icon in the +sidebar, the same marks a new-thread draft uses. On web and desktop, hover the row and choose the +**X** to discard that draft without opening the thread. + Right-click a pull request link in a thread and choose **Link to thread** to show that pull request in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it. From 01f3e50eca5102ccd881de6f942a98fe6a518ad4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 05:41:32 -0700 Subject: [PATCH 006/262] fix(server): unblock OpenCode approvals and stop (#9653) OpenCode could show an Approval badge with no controls, appear stuck on TodoWrite, and keep showing a running turn after Stop. - Show every permission, including old saved requests. Keep failed replies retryable and close completed requests even when reply events are lost. - Keep OpenCode output pipes drained and automatic replies out of the event loop. Handle disconnects, reconnects, and confirmed stops without stale requests or running states. - Show native task progress and command results. Do not treat TodoWrite or approval history as file edits or executed commands. - Ignore late aborts and task updates after a turn finishes. Fixes #4795 Fixes #7113 Fixes #5760 Created with GPT-6 Astra (preview) in Codex. Reviewed and merged with Claude Fable 5.1 in Claude Code. --- apps/mobile/src/lib/threadActivity.test.ts | 62 ++ apps/mobile/src/lib/threadActivity.ts | 10 +- .../Layers/ProjectionPipeline.test.ts | 82 +- .../Layers/ProjectionPipeline.ts | 38 + .../Layers/ProviderRuntimeIngestion.test.ts | 190 ++++ .../Layers/ProviderRuntimeIngestion.ts | 28 +- .../provider/Layers/OpenCodeAdapter.test.ts | 886 ++++++++++++++++-- .../src/provider/Layers/OpenCodeAdapter.ts | 480 ++++++++-- .../opencodeRuntime.environment.test.ts | 87 ++ .../opencodeRuntime.inventory.test.ts | 42 +- .../opencodeRuntime.permissions.test.ts | 56 +- apps/server/src/provider/opencodeRuntime.ts | 69 +- apps/web/src/session-logic.test.ts | 35 +- apps/web/src/session-logic.ts | 10 +- docs/user/providers-opencode.md | 37 +- .../src/work-log/presentation.test.ts | 34 + .../src/work-log/presentation.ts | 7 + 17 files changed, 1935 insertions(+), 218 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index cca0bf6890f5..29d59dae687b 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -263,6 +263,68 @@ describe("pending user input answers", () => { }); describe("pending approvals", () => { + it.each([{}, { requestType: "unknown" }])( + "exposes legacy OpenCode approvals without a known request kind: %j", + (legacyPayload) => { + const requested = makeActivity({ + id: EventId.make("approval-legacy"), + kind: "approval.requested", + summary: "Approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { requestId: "per-legacy", detail: "*", ...legacyPayload }, + }); + + expect(derivePendingApprovals([requested])).toEqual([ + { + requestId: "per-legacy", + requestKind: "command", + createdAt: requested.createdAt, + detail: "*", + }, + ]); + }, + ); + + it.each(["tool_user_input", "auth_tokens_refresh"])( + "does not turn %s into an approval", + (requestType) => { + const activity = makeActivity({ + id: EventId.make("approval-non-approval"), + kind: "approval.requested", + summary: "Approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { requestId: "not-an-approval", requestType }, + }); + + expect(derivePendingApprovals([activity])).toEqual([]); + }, + ); + + it.each(["approval.resolved", "provider.approval.respond.failed"])( + "removes legacy approvals after %s", + (kind) => { + const requested = makeActivity({ + id: EventId.make("approval-legacy-open"), + kind: "approval.requested", + summary: "Approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { requestId: "per-legacy", requestType: "unknown" }, + }); + const resolved = makeActivity({ + id: EventId.make("approval-legacy-resolved"), + kind, + summary: "Approval resolved", + createdAt: "2026-08-24T00:00:01.000Z", + payload: { + requestId: "per-legacy", + detail: "Unknown pending permission request: per-legacy", + }, + }); + + expect(derivePendingApprovals([requested, resolved])).toEqual([]); + }, + ); + it("keeps app access approvals and persistence choices from remote environments", () => { const options = [ { decision: "decline", label: "Decline" }, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index a034330006c4..88e957eab44a 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1754,10 +1754,16 @@ export function derivePendingApprovals( ? payload.options.filter(isProviderApprovalOption) : undefined; - if (activity.kind === "approval.requested" && requestId && requestKind) { + if ( + activity.kind === "approval.requested" && + requestId && + payload?.requestType !== "tool_user_input" && + payload?.requestType !== "auth_tokens_refresh" + ) { openByRequestId.set(requestId, { requestId, - requestKind, + // Older OpenCode requests can have no recognized approval kind. + requestKind: requestKind ?? "command", createdAt: activity.createdAt, ...(detail ? { detail } : {}), ...(appName ? { appName } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1b8a451175f3..9e4f88a5be10 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1,4 +1,5 @@ import { + ApprovalRequestId, CheckpointRef, CommandId, CorrelationId, @@ -2744,7 +2745,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("ignores non-stale provider approval response failures", () => + it.effect("restores pending approvals when a provider reply fails", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2829,6 +2830,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + yield* appendAndProject({ + type: "thread.approval-response-requested", + eventId: EventId.make("evt-nonstale-approval-response"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:02.500Z", + commandId: CommandId.make("cmd-nonstale-approval-response"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-response"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + requestId: ApprovalRequestId.make("approval-request-nonstale-existing"), + decision: "accept", + createdAt: "2026-02-26T12:45:02.500Z", + }, + }); + yield* appendAndProject({ type: "thread.activity-appended", eventId: EventId.make("evt-nonstale-approval-4"), @@ -2921,6 +2940,67 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { WHERE thread_id = 'thread-nonstale-approval' `; assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-resolved"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:05.000Z", + commandId: CommandId.make("cmd-nonstale-approval-resolved"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-resolved"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-resolved"), + tone: "approval", + kind: "approval.resolved", + summary: "Approval resolved", + payload: { + requestId: "approval-request-nonstale-existing", + decision: "accept", + }, + turnId: null, + createdAt: "2026-02-26T12:45:05.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-late-failure"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:06.000Z", + commandId: CommandId.make("cmd-nonstale-approval-late-failure"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-late-failure"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-late-failure"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-nonstale-existing", + detail: "Provider timed out while responding to approval request", + }, + turnId: null, + createdAt: "2026-02-26T12:45:06.000Z", + }, + }, + }); + + const resolvedThreadRows = yield* sql<{ readonly pendingApprovalCount: number }>` + SELECT pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + WHERE thread_id = 'thread-nonstale-approval' + `; + assert.deepEqual(resolvedThreadRows, [{ pendingApprovalCount: 0 }]); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1ba1b6afa4f3..ee07f9fb4cdc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1728,6 +1728,44 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; } + if (Option.isNone(existingRow) || existingRow.value.status !== "resolved") { + return; + } + + // Sending a reply clears the badge before the provider accepts it. + // A failed reply must restore the request unless a terminal event + // already closed it, including a reply from another client. + const requestActivities = (yield* projectionThreadActivityRepository.listByThreadId({ + threadId: existingRow.value.threadId, + })).filter((activity) => extractActivityRequestId(activity.payload) === requestId); + const wasRequested = requestActivities.some( + (activity) => activity.kind === "approval.requested", + ); + const wasResolved = requestActivities.some((activity) => { + if (activity.kind === "approval.resolved") { + return true; + } + if (activity.kind !== "provider.approval.respond.failed") { + return false; + } + const activityPayload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + return isStalePendingApprovalFailureDetail( + typeof activityPayload?.detail === "string" + ? activityPayload.detail.toLowerCase() + : null, + ); + }); + if (wasRequested && !wasResolved) { + yield* projectionPendingApprovalRepository.upsert({ + ...existingRow.value, + status: "pending", + decision: null, + resolvedAt: null, + }); + } return; } // Only approval-requested activities should create pending-approval diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index ef9300a196ad..828d48508638 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -371,6 +371,196 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); + it.each([ + { delivery: "buffered", enableLegacyTokenStreaming: false }, + { delivery: "streamed", enableLegacyTokenStreaming: true }, + ])("settles OpenCode aborted turns and saves $delivery assistant text", async (settings) => { + const harness = await createHarness({ + serverSettings: { enableLegacyTokenStreaming: settings.enableLegacyTokenStreaming }, + }); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("opencode-aborted-turn"); + const base = { + provider: ProviderDriverKind.make("opencode"), + threadId, + turnId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + harness.emit({ ...base, type: "turn.started", eventId: asEventId("opencode-started") }); + harness.emit({ + ...base, + type: "content.delta", + eventId: asEventId("opencode-partial-text"), + itemId: asItemId("opencode-text-part"), + payload: { streamKind: "assistant_text", delta: "Work before the stop." }, + }); + harness.emit({ + ...base, + type: "turn.aborted", + eventId: asEventId("opencode-aborted"), + createdAt: "2026-01-01T00:00:02.000Z", + payload: { reason: "Interrupted by user." }, + }); + + await harness.drain(); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session).toMatchObject({ + status: "interrupted", + activeTurnId: null, + lastError: null, + }); + expect(thread?.latestTurn).toMatchObject({ + turnId, + state: "interrupted", + completedAt: "2026-01-01T00:00:02.000Z", + }); + expect(thread?.messages).toEqual([ + expect.objectContaining({ + role: "assistant", + turnId, + text: "Work before the stop.", + streaming: false, + }), + ]); + }); + + it.each([ + { source: "the previous turn", turnId: asTurnId("opencode-stopped-turn") }, + { source: "an unspecified turn", turnId: undefined }, + ])("ignores late OpenCode aborts for $source across newer turns", async (lateAbort) => { + const harness = await createHarness({ + serverSettings: { enableLegacyTokenStreaming: true }, + }); + const threadId = asThreadId("thread-1"); + const stoppedTurnId = asTurnId("opencode-stopped-turn"); + const nextTurnId = asTurnId("opencode-next-turn"); + const base = { + provider: ProviderDriverKind.make("opencode"), + threadId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + harness.emit({ + ...base, + type: "turn.started", + eventId: asEventId("opencode-first-started"), + turnId: stoppedTurnId, + }); + harness.emit({ + ...base, + type: "turn.aborted", + eventId: asEventId("opencode-first-aborted"), + turnId: stoppedTurnId, + payload: { reason: "Interrupted by user." }, + }); + harness.emit({ + ...base, + type: "turn.started", + eventId: asEventId("opencode-next-started"), + turnId: nextTurnId, + }); + harness.emit({ + ...base, + type: "content.delta", + eventId: asEventId("opencode-next-partial-text"), + turnId: nextTurnId, + itemId: asItemId("opencode-next-text-part"), + payload: { streamKind: "assistant_text", delta: "The next turn is running." }, + }); + await harness.drain(); + + harness.emit({ + ...base, + type: "turn.aborted", + eventId: asEventId("opencode-late-abort"), + ...(lateAbort.turnId ? { turnId: lateAbort.turnId } : {}), + payload: { reason: "Interrupted by user." }, + }); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session).toMatchObject({ status: "running", activeTurnId: nextTurnId }); + expect(thread?.latestTurn).toMatchObject({ turnId: nextTurnId, state: "running" }); + expect(thread?.messages).toEqual([ + expect.objectContaining({ + turnId: nextTurnId, + text: "The next turn is running.", + streaming: true, + }), + ]); + + harness.emit({ + ...base, + type: "turn.completed", + eventId: asEventId("opencode-next-completed"), + turnId: nextTurnId, + createdAt: "2026-01-01T00:00:02.000Z", + payload: { state: "completed" }, + }); + await harness.drain(); + + const pendingAt = "2026-01-01T00:00:03.000Z"; + for (const hasPendingStart of [false, true]) { + if (hasPendingStart) { + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("opencode-pending-start"), + threadId, + message: { + messageId: asMessageId("opencode-pending-message"), + role: "user", + text: "Start another turn.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: pendingAt, + }); + harness.emit({ + ...base, + type: "session.state.changed", + eventId: asEventId("opencode-pending-starting"), + createdAt: pendingAt, + payload: { state: "starting" }, + }); + } + harness.emit({ + ...base, + type: "turn.aborted", + eventId: asEventId(`opencode-late-abort-after-completion-${hasPendingStart}`), + ...(lateAbort.turnId ? { turnId: lateAbort.turnId } : {}), + createdAt: "2026-01-01T00:00:04.000Z", + payload: { reason: "Interrupted by user." }, + }); + await harness.drain(); + + const completedThread = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + expect(completedThread?.session).toMatchObject({ + status: hasPendingStart ? "starting" : "ready", + activeTurnId: null, + }); + expect(completedThread?.latestTurn).toMatchObject({ turnId: nextTurnId, state: "completed" }); + } + + harness.emit({ + ...base, + type: "turn.started", + eventId: asEventId("opencode-pending-started"), + turnId: asTurnId("opencode-pending-turn"), + createdAt: "2026-01-01T00:00:05.000Z", + }); + await harness.drain(); + const startedThread = (await harness.readModel()).threads.find( + (entry) => entry.id === threadId, + ); + expect(startedThread?.latestTurn).toMatchObject({ + turnId: asTurnId("opencode-pending-turn"), + state: "running", + requestedAt: pendingAt, + }); + }); + it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index f78088675045..257c67b18eb5 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1578,6 +1578,7 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const isTerminalTurn = event.type === "turn.completed" || event.type === "turn.aborted"; const isCompactedThreadState = event.type === "thread.state.changed" && event.payload.state === "compacted"; const pendingTurnStart = @@ -1586,7 +1587,7 @@ const make = Effect.gen(function* () { event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" || + isTerminalTurn || isCompactedThreadState ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({ threadId: thread.id, @@ -1624,6 +1625,7 @@ const make = Effect.gen(function* () { case "turn.started": return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; case "turn.completed": + case "turn.aborted": if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } @@ -1631,14 +1633,10 @@ const make = Effect.gen(function* () { if (activeTurnId !== null && eventTurnId !== undefined) { return sameId(activeTurnId, eventTurnId); } - // No active turn tracked: accept only completions that name their - // turn (covers a real completion whose turn.started was lost). An - // untargeted completion cannot prove it belongs to any turn this - // thread ran โ€” the known emitter was the Claude resume handshake - // (system/init + result(num_turns: 0)), which is not a turn at - // all โ€” and applying it here stomps the "starting" lifecycle - // state while a turn start is pending. - return eventTurnId !== undefined; + // A named completion can recover a lost turn.started event. + // An abort needs an active turn so a delayed stop cannot replace + // a ready session or clear a newer pending start. + return event.type === "turn.completed" && eventTurnId !== undefined; default: return true; } @@ -1654,7 +1652,7 @@ const make = Effect.gen(function* () { event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" + isTerminalTurn ) { const status = (() => { switch (event.type) { @@ -1666,6 +1664,8 @@ const make = Effect.gen(function* () { return "running"; case "session.exited": return "stopped"; + case "turn.aborted": + return "interrupted"; case "turn.completed": return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" @@ -1680,7 +1680,7 @@ const make = Effect.gen(function* () { const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" + : isTerminalTurn || event.type === "session.exited" ? null : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn( @@ -1694,7 +1694,7 @@ const make = Effect.gen(function* () { : event.type === "turn.completed" && normalizeRuntimeTurnState(event.payload.state) === "failed" ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") - : status === "ready" + : status === "ready" || status === "interrupted" ? null : (thread.session?.lastError ?? null); @@ -1922,7 +1922,7 @@ const make = Effect.gen(function* () { }); } - if (event.type === "turn.completed") { + if (isTerminalTurn) { const detailedThread = yield* getLoadedThreadDetail(); const messages = detailedThread?.messages ?? []; const proposedPlans = detailedThread?.proposedPlans ?? []; @@ -2055,7 +2055,7 @@ const make = Effect.gen(function* () { } else if (!conflictsWithActiveTurn) { if (event.type === "turn.plan.updated") { threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan); - } else if (event.type === "turn.completed" || event.type === "turn.aborted") { + } else if (isTerminalTurn && shouldApplyThreadLifecycle) { threadPlanProgress.clearThreadPlanProgress(thread.id); } } diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index dfa6f20be7c0..261726de2146 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -15,7 +15,11 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; -import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; +import type { + Event as OpenCodeEvent, + PermissionRequest, + QuestionRequest, +} from "@opencode-ai/sdk/v2"; import { ApprovalRequestId, @@ -85,23 +89,30 @@ const runtimeMock = { promptAsyncImplementation: null as (() => Promise) | null, autoPromptEcho: true, autoConnect: true, + endEventStream: false, promptEchoEvents: [] as Array, closeError: null as Error | null, messages: [] as MessageEntry[], subscribedEvents: [] as Array>, eventSubscribeObserved: null as (() => void) | null, + eventStreamError: null as ((cause: unknown) => void) | null, permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, - permissionReplyImplementation: null as (() => Promise) | null, + permissionReplyImplementation: null as ((signal?: AbortSignal) => Promise) | null, + permissionReplySignals: [] as AbortSignal[], questionReplyCalls: [] as Array<{ requestID: string; answers: ReadonlyArray>; }>, + questionReplyImplementation: null as ((signal?: AbortSignal) => Promise) | null, sessionStatus: "idle" as "idle" | "busy", sessionStatusFailures: 0, sessionStatusCalls: 0, sessionStatusImplementation: null as (() => Promise) | null, sessionGetIds: [] as string[], sessionGetObserved: null as ((sessionID: string) => void) | null, + sessionGetImplementation: null as + | ((sessionID: string, signal?: AbortSignal) => Promise) + | null, missingSessionIds: new Set(), transientErrorSessionIds: new Set(), sessionDirectoryById: new Map(), @@ -137,20 +148,25 @@ const runtimeMock = { this.state.promptAsyncImplementation = null; this.state.autoPromptEcho = true; this.state.autoConnect = true; + this.state.endEventStream = false; this.state.promptEchoEvents.length = 0; this.state.closeError = null; this.state.messages = []; this.state.subscribedEvents = []; this.state.eventSubscribeObserved = null; + this.state.eventStreamError = null; this.state.permissionReplyCalls.length = 0; this.state.permissionReplyImplementation = null; + this.state.permissionReplySignals.length = 0; this.state.questionReplyCalls.length = 0; + this.state.questionReplyImplementation = null; this.state.sessionStatus = "idle"; this.state.sessionStatusFailures = 0; this.state.sessionStatusCalls = 0; this.state.sessionStatusImplementation = null; this.state.sessionGetIds.length = 0; this.state.sessionGetObserved = null; + this.state.sessionGetImplementation = null; this.state.missingSessionIds.clear(); this.state.transientErrorSessionIds.clear(); this.state.sessionDirectoryById.clear(); @@ -222,9 +238,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { data: { id: runtimeMock.state.createdSessionIds.shift() ?? `${baseUrl}/session` }, }; }, - get: async ({ sessionID }: { sessionID: string }) => { + get: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => { runtimeMock.state.sessionGetIds.push(sessionID); runtimeMock.state.sessionGetObserved?.(sessionID); + if (runtimeMock.state.sessionGetImplementation) { + await runtimeMock.state.sessionGetImplementation(sessionID, options?.signal); + } // The real client is `throwOnError: true`: non-2xx rejects rather // than resolving, so missing โ†’ 404 throw, transient โ†’ 500 throw. if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { @@ -264,6 +283,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runtimeMock.state.abortSignals.push(options.signal); } await runtimeMock.state.abortImplementation?.(sessionID, options?.signal); + runtimeMock.state.pendingPermissions = runtimeMock.state.pendingPermissions.filter( + (request) => request.sessionID !== sessionID, + ); + runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter( + (request) => request.sessionID !== sessionID, + ); }, children: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.sessionChildrenCalls.push(sessionID); @@ -357,19 +382,60 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, }, event: { - subscribe: async () => { + subscribe: async ( + _input: unknown, + options?: { signal?: AbortSignal; onSseError?: (cause: unknown) => void }, + ) => { runtimeMock.state.eventSubscribeObserved?.(); + runtimeMock.state.eventStreamError = options?.onSseError ?? null; return { stream: (async function* () { - if (runtimeMock.state.autoConnect) { - yield { id: "evt-auto-connected", type: "server.connected", properties: {} }; - } - for (const event of runtimeMock.state.subscribedEvents) { - const resolved = await event; - while (runtimeMock.state.promptEchoEvents.length > 0) { - yield runtimeMock.state.promptEchoEvents.shift(); + const aborted = promiseWithResolvers(); + const onAbort = () => aborted.resolve(undefined); + options?.signal?.addEventListener("abort", onAbort, { once: true }); + try { + if (runtimeMock.state.autoConnect) { + yield { id: "evt-auto-connected", type: "server.connected", properties: {} }; + } + for (const event of runtimeMock.state.subscribedEvents) { + if (options?.signal?.aborted) return; + const resolved = await Promise.race([event, aborted.promise]); + if (options?.signal?.aborted) return; + while (runtimeMock.state.promptEchoEvents.length > 0) { + yield runtimeMock.state.promptEchoEvents.shift(); + } + const nativeEvent = resolved as OpenCodeEvent; + if (nativeEvent.type === "permission.asked") { + runtimeMock.state.pendingPermissions = + runtimeMock.state.pendingPermissions.filter( + (request) => request.id !== nativeEvent.properties.id, + ); + runtimeMock.state.pendingPermissions.push(nativeEvent.properties); + } else if (nativeEvent.type === "permission.replied") { + runtimeMock.state.pendingPermissions = + runtimeMock.state.pendingPermissions.filter( + (request) => request.id !== nativeEvent.properties.requestID, + ); + } else if (nativeEvent.type === "question.asked") { + runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter( + (request) => request.id !== nativeEvent.properties.id, + ); + runtimeMock.state.pendingQuestions.push(nativeEvent.properties); + } else if ( + nativeEvent.type === "question.replied" || + nativeEvent.type === "question.rejected" + ) { + runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter( + (request) => request.id !== nativeEvent.properties.requestID, + ); + } + yield resolved; } - yield resolved; + if (!runtimeMock.state.endEventStream && !options?.signal?.aborted) { + await aborted.promise; + } + } finally { + options?.signal?.removeEventListener("abort", onAbort); } })(), }; @@ -384,11 +450,18 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : runtimeMock.state.pendingPermissions, }; }, - reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { + reply: async ( + { requestID, reply }: { requestID: string; reply: string }, + options?: { signal?: AbortSignal }, + ) => { runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); + if (options?.signal) runtimeMock.state.permissionReplySignals.push(options.signal); if (runtimeMock.state.permissionReplyImplementation) { - await runtimeMock.state.permissionReplyImplementation(); + await runtimeMock.state.permissionReplyImplementation(options?.signal); } + runtimeMock.state.pendingPermissions = runtimeMock.state.pendingPermissions.filter( + (request) => request.id !== requestID, + ); }, }, question: { @@ -400,14 +473,21 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : runtimeMock.state.pendingQuestions, }; }, - reply: async ({ - requestID, - answers, - }: { - requestID: string; - answers: ReadonlyArray>; - }) => { + reply: async ( + { + requestID, + answers, + }: { + requestID: string; + answers: ReadonlyArray>; + }, + options?: { signal?: AbortSignal }, + ) => { runtimeMock.state.questionReplyCalls.push({ requestID, answers }); + await runtimeMock.state.questionReplyImplementation?.(options?.signal); + runtimeMock.state.pendingQuestions = runtimeMock.state.pendingQuestions.filter( + (request) => request.id !== requestID, + ); }, }, }) as unknown as ReturnType, @@ -2576,6 +2656,411 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect.each([ + { permission: "external_directory", decision: "accept", reply: "once" }, + { permission: "doom_loop", decision: "acceptForSession", reply: "always" }, + { permission: "todowrite", decision: "decline", reply: "reject" }, + { permission: "webfetch", decision: "cancel", reply: "reject" }, + { permission: "custom_tool", decision: "accept", reply: "once" }, + ] as const)( + "shows $permission approval and resolves its $decision reply without SSE", + ({ permission, decision, reply }) => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-permission-${permission}`); + const request = { + ...permissionRequest(`per_${permission}`, "http://127.0.0.1:9999/session"), + permission, + patterns: ["*"], + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-permission", + type: "permission.asked", + properties: request, + } satisfies OpenCodeEvent, + ]; + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + const opened = Option.getOrThrow(yield* Fiber.join(openedFiber)); + NodeAssert.ok(opened.type === "request.opened"); + NodeAssert.equal(opened.payload.requestType, "command_execution_approval"); + NodeAssert.equal(opened.payload.detail, permission.replaceAll("_", " ")); + NodeAssert.deepEqual( + opened.payload.options?.map((option) => option.label), + ["Allow once", "Allow for workspace", "Deny"], + ); + const resolvedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "request.resolved", + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), decision); + const resolved = Option.getOrThrow(yield* Fiber.join(resolvedFiber)); + NodeAssert.equal(resolved.requestId, request.id); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), decision); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: request.id, reply }, + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a permission reply retryable after its HTTP request times out", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-permission-timeout"); + const request = permissionRequest("per_timeout", "http://127.0.0.1:9999/session"); + const replyStarted = promiseWithResolvers(); + runtimeMock.state.permissionReplyImplementation = async () => { + replyStarted.resolve(undefined); + await new Promise(() => {}); + }; + runtimeMock.state.subscribedEvents = [ + { id: "evt-ask", type: "permission.asked", properties: request }, + ]; + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Fiber.join(openedFiber); + const replyFiber = yield* adapter + .respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept") + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.promise(() => replyStarted.promise); + yield* Effect.yieldNow; + yield* advanceTestClock(10_000); + NodeAssert.equal(Exit.isFailure(yield* Fiber.join(replyFiber)), true); + NodeAssert.equal(runtimeMock.state.permissionReplySignals[0]?.aborted, true); + runtimeMock.state.permissionReplyImplementation = null; + const resolvedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.resolved"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"); + NodeAssert.equal(Option.getOrThrow(yield* Fiber.join(resolvedFiber)).requestId, request.id); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a recovering permission retryable until its native request is loaded", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-permission-recovering"); + const request = permissionRequest("per_recovering", "ses_resumed"); + const listStarted = promiseWithResolvers(); + const releaseList = promiseWithResolvers(); + runtimeMock.state.permissionListImplementation = async () => { + listStarted.resolve(undefined); + return await releaseList.promise; + }; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + resumeCursor: { schemaVersion: 1, sessionId: request.sessionID }, + }); + yield* Effect.promise(() => listStarted.promise); + const reply = yield* adapter + .respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept") + .pipe(Effect.result); + NodeAssert.equal(reply._tag, "Failure"); + if (reply._tag === "Failure" && reply.failure._tag === "ProviderAdapterRequestError") { + NodeAssert.match(reply.failure.detail, /still loading/); + } + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + releaseList.resolve([request]); + yield* Fiber.join(openedFiber); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: request.id, reply: "once" }, + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("closes missing permissions and questions after reconnect", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-missing-requests"); + const sessionID = "http://127.0.0.1:9999/session"; + const reconnect = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + { + id: "evt-permission", + type: "permission.asked", + properties: permissionRequest("per_missing", sessionID), + }, + { + id: "evt-question", + type: "question.asked", + properties: questionRequest("que_missing", sessionID), + }, + reconnect.promise, + ]; + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "user-input.requested"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Fiber.join(openedFiber); + const resolvedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.resolved" || event.type === "user-input.resolved"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + runtimeMock.state.pendingPermissions = []; + runtimeMock.state.pendingQuestions = []; + reconnect.resolve({ id: "evt-reconnected", type: "server.connected", properties: {} }); + const resolved = yield* Fiber.join(resolvedFiber); + NodeAssert.deepEqual( + resolved.map((event) => event.requestId), + ["per_missing", "que_missing"], + ); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []); + NodeAssert.deepEqual(runtimeMock.state.questionReplyCalls, []); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("closes pending requests after Stop and ignores late requests from that turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stop-requests"); + const sessionID = "http://127.0.0.1:9999/session"; + const startRequests = promiseWithResolvers(); + const lateRequests = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + startRequests.promise, + { + id: "evt-question", + type: "question.asked", + properties: questionRequest("que_stop", sessionID), + }, + lateRequests.promise, + { + id: "evt-late-question", + type: "question.asked", + properties: questionRequest("que_late", sessionID), + }, + { id: "evt-drained", type: "session.compacted", properties: { sessionID } }, + ]; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "user-input.requested"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + startRequests.resolve({ + id: "evt-permission", + type: "permission.asked", + properties: permissionRequest("per_stop", sessionID), + }); + yield* Fiber.join(openedFiber); + const stoppedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.aborted"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.interruptTurn(threadId, turn.turnId); + const stopped = yield* Fiber.join(stoppedFiber); + NodeAssert.deepEqual( + stopped.map((event) => event.type), + ["request.resolved", "user-input.resolved", "turn.aborted"], + ); + const lateFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + lateRequests.resolve({ + id: "evt-late-permission", + type: "permission.asked", + properties: permissionRequest("per_late", sessionID), + }); + const late = yield* Fiber.join(lateFiber); + NodeAssert.deepEqual( + late.map((event) => event.type), + ["thread.state.changed"], + ); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps progress live during automatic approval and never reopens a finished turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-auto-approval-progress"); + const sessionID = "http://127.0.0.1:9999/session"; + const ask = promiseWithResolvers(); + const idle = promiseWithResolvers(); + const replyStarted = promiseWithResolvers(); + const releaseReply = promiseWithResolvers(); + runtimeMock.state.permissionReplyImplementation = async () => { + replyStarted.resolve(undefined); + await releaseReply.promise; + throw new Error("reply response lost"); + }; + runtimeMock.state.subscribedEvents = [ask.promise, idle.promise]; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "Work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + ask.resolve({ + id: "evt-ask", + type: "permission.asked", + properties: permissionRequest("per_slow_auto", sessionID), + }); + yield* Effect.promise(() => replyStarted.promise); + idle.resolve({ + id: "evt-idle", + type: "session.status", + properties: { sessionID, status: { type: "idle" } }, + }); + const completed = yield* Fiber.join(completedFiber); + NodeAssert.equal( + completed.some((event) => event.type === "request.opened"), + false, + ); + const remainingFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); + releaseReply.resolve(undefined); + yield* advanceTestClock(10_000); + yield* adapter.stopSession(threadId); + const remaining = yield* Fiber.join(remainingFiber); + NodeAssert.equal( + remaining.some((event) => event.type === "request.opened"), + false, + ); + }), + ); + + it.effect("keeps automatic approval fallback available after a steer", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-auto-approval-steer"); + const ask = promiseWithResolvers(); + const replyStarted = promiseWithResolvers(); + const releaseReply = promiseWithResolvers(); + runtimeMock.state.sessionStatus = "busy"; + runtimeMock.state.permissionReplyImplementation = async () => { + replyStarted.resolve(undefined); + await releaseReply.promise; + throw new Error("reply failed"); + }; + runtimeMock.state.subscribedEvents = [ask.promise]; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const modelSelection = createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ); + const turn = yield* adapter.sendTurn({ threadId, input: "Work", modelSelection }); + ask.resolve({ + id: "evt-ask", + type: "permission.asked", + properties: permissionRequest("per_steer_auto", "http://127.0.0.1:9999/session"), + }); + yield* Effect.promise(() => replyStarted.promise); + const steered = yield* adapter.sendTurn({ + threadId, + input: "Keep the change small", + modelSelection, + }); + NodeAssert.equal(steered.turnId, turn.turnId); + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + releaseReply.resolve(undefined); + NodeAssert.equal( + Option.getOrThrow(yield* Fiber.join(openedFiber)).requestId, + "per_steer_auto", + ); + runtimeMock.state.permissionReplyImplementation = null; + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_steer_auto"), "accept"); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("routes child-session approval requests and replies through the parent thread", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -2691,6 +3176,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId(`thread-full-access-${requestId}`); + const replyStarted = promiseWithResolvers(); + runtimeMock.state.permissionReplyImplementation = async () => + replyStarted.resolve(undefined); runtimeMock.state.subscribedEvents = [ { id: "evt-child-created", @@ -2709,11 +3197,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { type: "permission.asked", properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always }, }, - { + replyStarted.promise.then(() => ({ id: "evt-permission-replied", type: "permission.replied", properties: { sessionID, requestID: requestId, reply: "once" }, - }, + })), // The suppressed ask emits nothing, so an empty question serves as a // sentinel that closes the collected stream once the pump is past it. { @@ -3000,53 +3488,68 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("retries ancestry for one live child request after a transient failure", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-child-request-ancestry-retry"); - const parentId = "http://127.0.0.1:9999/session"; - const ancestryAttempted = promiseWithResolvers(); - runtimeMock.state.sessionParentById.set("ses_existing_child", parentId); - runtimeMock.state.transientErrorSessionIds.add("ses_existing_child"); - runtimeMock.state.sessionGetObserved = (sessionID) => { - if (sessionID === "ses_existing_child") { - ancestryAttempted.resolve(undefined); + it.effect.each(["failure", "timeout"] as const)( + "retries ancestry for a child request after a transient %s", + (lookupFailure) => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-child-request-ancestry-retry-${lookupFailure}`); + const parentId = "http://127.0.0.1:9999/session"; + const ancestryAttempted = promiseWithResolvers(); + runtimeMock.state.sessionParentById.set("ses_existing_child", parentId); + runtimeMock.state.transientErrorSessionIds.add("ses_existing_child"); + let lookupSignal: AbortSignal | undefined; + if (lookupFailure === "timeout") { + runtimeMock.state.sessionGetImplementation = async (_sessionID, signal) => { + lookupSignal = signal; + await new Promise(() => {}); + }; } - }; - runtimeMock.state.subscribedEvents = [ - { - id: "evt-existing-child-permission", - type: "permission.asked", - properties: permissionRequest("per_retry", "ses_existing_child"), - }, - ]; + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === "ses_existing_child") { + ancestryAttempted.resolve(undefined); + } + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-existing-child-permission", + type: "permission.asked", + properties: permissionRequest("per_retry", "ses_existing_child"), + }, + ]; - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter( - (event) => - event.threadId === threadId && - (event.type === "runtime.warning" || event.type === "request.opened"), - ), - Stream.take(2), - Stream.runCollect, - Effect.forkChild, - ); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "approval-required", - }); - yield* Effect.promise(() => ancestryAttempted.promise); - runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child"); - yield* advanceTestClock(250); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "runtime.warning" || event.type === "request.opened"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Effect.promise(() => ancestryAttempted.promise); + if (lookupFailure === "timeout") { + yield* Effect.yieldNow; + yield* advanceTestClock(10_000); + NodeAssert.equal(lookupSignal?.aborted, true); + runtimeMock.state.sessionGetImplementation = null; + } + runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child"); + yield* advanceTestClock(250); - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - NodeAssert.deepEqual( - events.map((event) => event.type), - ["runtime.warning", "request.opened"], - ); - yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept"); - }), + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["runtime.warning", "request.opened"], + ); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept"); + }), ); it.effect("does not resurrect a recovered child request after its live reply", () => @@ -3101,7 +3604,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const response = yield* Effect.exit( adapter.respondToRequest(threadId, ApprovalRequestId.make(stale.id), "accept"), ); - NodeAssert.equal(Exit.isFailure(response), true); + NodeAssert.equal(Exit.isSuccess(response), true); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []); }), ); @@ -3153,7 +3657,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const response = yield* Effect.exit( adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"), ); - NodeAssert.equal(Exit.isFailure(response), true); + NodeAssert.equal(Exit.isSuccess(response), true); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, []); }), ); @@ -5286,6 +5791,249 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("maps native task progress only while a turn is active", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-native-progress"); + const sessionID = "http://127.0.0.1:9999/session"; + const startProgress = promiseWithResolvers(); + const finishTurn = promiseWithResolvers(); + const lateProgress = promiseWithResolvers(); + const todos = [ + { content: "Read files", status: "completed", priority: "high" }, + { content: "Fix OpenCode", status: "in_progress", priority: "high" }, + { content: "Run tests", status: "pending", priority: "medium" }, + { content: "Old task", status: "cancelled", priority: "low" }, + ]; + const todoEvent = { + id: "evt-todos", + type: "todo.updated", + properties: { sessionID, todos }, + } satisfies OpenCodeEvent; + runtimeMock.state.subscribedEvents = [ + startProgress.promise, + ...["todowrite", "bash"].map( + (tool) => + ({ + id: `evt-${tool}`, + type: "message.part.updated", + properties: { + sessionID, + time: 2, + part: { + id: `part-${tool}`, + sessionID, + messageID: "msg-tools", + type: "tool", + callID: `call-${tool}`, + tool, + state: { + status: "completed", + input: tool === "bash" ? { command: "pwd" } : { todos }, + output: tool === "bash" ? "/repo\n" : "Tasks updated", + title: tool === "bash" ? "Working directory" : "Tasks updated", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + }, + }) satisfies OpenCodeEvent, + ), + finishTurn.promise, + lateProgress.promise, + { id: "evt-progress-drained", type: "session.compacted", properties: { sessionID } }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.plan.updated" || event.type === "item.completed"), + ), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Work through the task list", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + startProgress.resolve(todoEvent); + const events = yield* Fiber.join(eventsFiber); + const plan = events.find((event) => event.type === "turn.plan.updated"); + NodeAssert.equal(plan?.turnId, turn.turnId); + NodeAssert.deepEqual(plan?.payload.plan, [ + { step: "Read files", status: "completed" }, + { step: "Fix OpenCode", status: "inProgress" }, + { step: "Run tests", status: "pending" }, + ]); + const tools = events.filter((event) => event.type === "item.completed"); + NodeAssert.equal(tools[0]?.payload.itemType, "dynamic_tool_call"); + NodeAssert.equal(tools[1]?.payload.title, "Working directory"); + NodeAssert.partialDeepStrictEqual(tools[1]?.payload.data, { + command: "pwd", + result: "/repo\n", + }); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + finishTurn.resolve({ + id: "evt-progress-completed", + type: "session.status", + properties: { sessionID, status: { type: "idle" } }, + }); + yield* Fiber.join(completedFiber); + const lateEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + lateProgress.resolve({ ...todoEvent, id: "evt-late-todos" }); + NodeAssert.deepEqual( + (yield* Fiber.join(lateEventsFiber)).map((event) => event.type), + ["thread.state.changed"], + ); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("warns on disconnection and recovers a completion missed during reconnect", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-reconnect-completion"); + const reconnect = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [reconnect.promise]; + runtimeMock.state.sessionStatus = "busy"; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const warningFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "runtime.warning"), + Stream.runHead, + Effect.forkChild, + ); + runtimeMock.state.eventStreamError?.(new Error("socket closed")); + const warning = Option.getOrThrow(yield* Fiber.join(warningFiber)); + NodeAssert.ok(warning.type === "runtime.warning"); + NodeAssert.equal(warning.payload.message, "OpenCode connection lost. Reconnecting."); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + runtimeMock.state.sessionStatus = "idle"; + reconnect.resolve({ + id: "evt-reconnected", + type: "server.connected", + properties: {}, + } satisfies OpenCodeEvent); + NodeAssert.equal(Option.getOrThrow(yield* Fiber.join(completedFiber)).turnId, turn.turnId); + NodeAssert.equal( + (yield* adapter.listSessions()).find((session) => session.threadId === threadId)?.status, + "ready", + ); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "ends a running session on clean stream closure without discarding unresolved permissions", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stream-closed"); + const endStream = promiseWithResolvers(); + const request = permissionRequest("per_disconnect", "http://127.0.0.1:9999/session"); + runtimeMock.state.pendingPermissions = [request]; + runtimeMock.state.subscribedEvents = [endStream.promise]; + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Fiber.join(openedFiber); + yield* adapter.sendTurn({ + threadId, + input: "Work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const exitedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); + runtimeMock.state.endEventStream = true; + runtimeMock.state.abortImplementation = async () => { + throw new Error("server unreachable"); + }; + endStream.resolve({ + id: "evt-busy", + type: "session.status", + properties: { sessionID: request.sessionID, status: { type: "busy" } }, + }); + const exited = yield* Fiber.join(exitedFiber); + NodeAssert.equal( + exited.some((event) => event.type === "request.resolved"), + false, + ); + NodeAssert.match( + exited.find((event) => event.type === "runtime.error")?.payload.message ?? "", + /event stream ended/, + ); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + runtimeMock.state.endEventStream = false; + runtimeMock.state.subscribedEvents = []; + runtimeMock.state.abortImplementation = null; + const recoveredFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + resumeCursor: session.resumeCursor, + }); + NodeAssert.equal( + Option.getOrThrow(yield* Fiber.join(recoveredFiber)).requestId, + request.id, + ); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index e0305236a717..9cce1e6b889e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -417,6 +417,9 @@ type EventBaseInput = { function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { const normalized = toolName.toLowerCase(); + if (normalized === "todowrite" || normalized === "todoread") { + return "dynamic_tool_call"; + } if (normalized.includes("bash") || normalized.includes("command")) { return "command_execution"; } @@ -449,16 +452,15 @@ function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { function mapPermissionToRequestType( permission: string, -): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { +): "command_execution_approval" | "file_read_approval" | "file_change_approval" { switch (permission) { - case "bash": - return "command_execution_approval"; case "read": return "file_read_approval"; case "edit": return "file_change_approval"; default: - return "unknown"; + // Every OpenCode permission needs an actionable approval in each client. + return "command_execution_approval"; } } @@ -1062,6 +1064,10 @@ export function makeOpenCodeAdapter( context.interruptedTurnId = undefined; context.awaitingBusyAfterInterruption = false; context.reconcileIdleStatus = false; + for (const requestId of context.autoRepliedRequestIds) { + context.emittedTerminalRequestIds.add(requestId); + } + context.autoRepliedRequestIds.clear(); applyProviderSessionUpdate( context, { status: "ready" }, @@ -1071,6 +1077,7 @@ export function makeOpenCodeAdapter( if (pendingIdleReconciliation?.fiber) { yield* Fiber.interrupt(pendingIdleReconciliation.fiber); } + yield* schedulePendingRequestRecovery(context); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1425,6 +1432,7 @@ export function makeOpenCodeAdapter( { clearActiveTurnId: true, clearLastError: true }, ); } + yield* clearPendingOpenCodeRequests(context, { type: "session.abort" }); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1578,7 +1586,19 @@ export function makeOpenCodeAdapter( const seen = new Set(); const getSession = (sessionID: string) => - runOpenCodeSdk("session.get", () => context.client.session.get({ sessionID })).pipe( + runOpenCodeSdk("session.get", (signal) => + context.client.session.get({ sessionID }, { signal }), + ).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "session.get", + detail: "OpenCode session ancestry lookup did not complete within 10 seconds.", + }), + ), + }), Effect.catchIf( (cause) => isOpenCodeNotFound(cause), () => Effect.succeed(undefined), @@ -1610,6 +1630,52 @@ export function makeOpenCodeAdapter( return false; }); + const openPermissionRequest = Effect.fn("openPermissionRequest")(function* ( + context: OpenCodeSessionContext, + request: PermissionRequest, + raw: unknown, + ) { + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + context.emittedTerminalRequestIds.has(request.id) || + context.pendingPermissions.has(request.id) + ) { + return; + } + const patterns = request.patterns.filter((pattern) => pattern !== "*"); + const detail = + request.permission === "bash" && patterns.length > 0 + ? patterns.join("\n") + : [request.permission.replaceAll("_", " "), ...patterns].join("\n"); + context.autoRepliedRequestIds.delete(request.id); + context.pendingPermissions.set(request.id, request); + emitUnsafe({ + ...base, + type: "request.opened", + payload: { + requestType: mapPermissionToRequestType(request.permission), + detail, + args: request.metadata, + options: [ + { decision: "accept", label: "Allow once" }, + { + decision: "acceptForSession", + label: "Allow for workspace", + warning: "Applies to matching requests in other OpenCode sessions in this workspace.", + }, + { decision: "decline", label: "Deny" }, + ], + }, + }); + }); + // Full access means the user already granted everything, but two upstream // paths never consult the session ruleset we send: doom-loop detection // (evaluated against the agent ruleset only) and subagent sessions (which @@ -1622,15 +1688,12 @@ export function makeOpenCodeAdapter( const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* ( context: OpenCodeSessionContext, request: PermissionRequest, + raw: unknown, ) { - // Mark before awaiting: retry and recovery fibers re-enter the ask path, - // and the matching `permission.replied` can arrive, while the SDK call - // is in flight. Marked ids skip the ask and swallow the terminal event. - context.resolvedRequestIds.add(request.id); - context.autoRepliedRequestIds.add(request.id); - const replied = yield* runOpenCodeSdk("permission.reply", () => - context.client.permission.reply({ requestID: request.id, reply: "once" }), + const replied = yield* runOpenCodeSdk("permission.reply", (signal) => + context.client.permission.reply({ requestID: request.id, reply: "once" }, { signal }), ).pipe( + Effect.timeout("10 seconds"), Effect.as(true), Effect.orElseSucceed(() => false), ); @@ -1638,9 +1701,8 @@ export function makeOpenCodeAdapter( // Fall back to the dialog. The id stays resolved so a recovered copy // of this ask cannot reopen after the user answers; // `pendingPermissions` gates re-asks while the dialog is open. - context.autoRepliedRequestIds.delete(request.id); + yield* openPermissionRequest(context, request, raw); } - return replied; }); const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* ( @@ -1651,39 +1713,26 @@ export function makeOpenCodeAdapter( if (context.resolvedRequestIds.has(event.properties.id)) { return; } + if (context.activeTurnId === undefined && context.reconcileIdleStatus) { + context.resolvedRequestIds.add(event.properties.id); + return; + } if (event.type === "permission.asked") { const request = event.properties; if (context.pendingPermissions.has(request.id)) { return; } - if ( - context.session.runtimeMode === "full-access" && - (yield* autoReplyFullAccess(context, request)) - ) { - return; - } - const base = yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId: request.id, - raw, - }); - // No yield between this check and the publish: a terminal - // `permission.replied` delivered on the pump in between would leave a - // dialog that can never close. - if (context.emittedTerminalRequestIds.has(request.id)) { + if (context.session.runtimeMode === "full-access") { + // Reply outside the event pump so a slow HTTP response cannot hide + // progress, terminal replies, or the acknowledgment for Stop. + context.resolvedRequestIds.add(request.id); + context.autoRepliedRequestIds.add(request.id); + yield* autoReplyFullAccess(context, request, raw).pipe( + Effect.forkIn(context.sessionScope), + ); return; } - context.pendingPermissions.set(request.id, request); - emitUnsafe({ - ...base, - type: "request.opened", - payload: { - requestType: mapPermissionToRequestType(request.permission), - detail: request.patterns.length > 0 ? request.patterns.join("\n") : request.permission, - args: request.metadata, - }, - }); + yield* openPermissionRequest(context, request, raw); return; } @@ -1691,14 +1740,19 @@ export function makeOpenCodeAdapter( if (context.pendingQuestions.has(request.id)) { return; } + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + const stopped = yield* Ref.get(context.stopped); + if (stopped || context.resolvedRequestIds.has(request.id)) { + return; + } context.pendingQuestions.set(request.id, request); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId: request.id, - raw, - })), + emitUnsafe({ + ...base, type: "user-input.requested", payload: { questions: normalizeQuestionRequest(request) }, }); @@ -1719,26 +1773,32 @@ export function makeOpenCodeAdapter( const emitTerminalOpenCodeRequest = Effect.fn("emitTerminalOpenCodeRequest")(function* ( context: OpenCodeSessionContext, event: OpenCodeTerminalRequestEvent, + raw: unknown = event, ) { const requestId = event.properties.requestID; if (context.emittedTerminalRequestIds.has(requestId)) { return; } - context.emittedTerminalRequestIds.add(requestId); if (context.autoRepliedRequestIds.delete(requestId)) { + context.emittedTerminalRequestIds.add(requestId); return; } + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId, + raw, + }); + if (context.emittedTerminalRequestIds.has(requestId)) return; + context.emittedTerminalRequestIds.add(requestId); if (event.type === "permission.replied") { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId, - raw: event, - })), + const request = context.pendingPermissions.get(requestId); + context.pendingPermissions.delete(requestId); + emitUnsafe({ + ...base, type: "request.resolved", payload: { - requestType: "unknown", + requestType: request ? mapPermissionToRequestType(request.permission) : "unknown", decision: mapPermissionDecision(event.properties.reply), }, }); @@ -1746,6 +1806,7 @@ export function makeOpenCodeAdapter( } const request = context.pendingQuestions.get(requestId); + context.pendingQuestions.delete(requestId); const answers = event.type === "question.replied" && request ? Object.fromEntries( @@ -1755,18 +1816,73 @@ export function makeOpenCodeAdapter( ]), ) : {}; - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId, - raw: event, - })), + emitUnsafe({ + ...base, type: "user-input.resolved", payload: { answers }, }); }); + const closePendingOpenCodeRequests = Effect.fn("closePendingOpenCodeRequests")(function* ( + context: OpenCodeSessionContext, + permissions: ReadonlyArray, + questions: ReadonlyArray, + raw: unknown, + ) { + for (const request of permissions) { + if (!context.pendingPermissions.has(request.id)) continue; + yield* resolvePendingOpenCodeRequest(context, request.id); + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + if (context.emittedTerminalRequestIds.has(request.id)) continue; + context.pendingPermissions.delete(request.id); + context.emittedTerminalRequestIds.add(request.id); + emitUnsafe({ + ...base, + type: "request.resolved", + payload: { requestType: mapPermissionToRequestType(request.permission) }, + }); + } + for (const request of questions) { + if (!context.pendingQuestions.has(request.id)) continue; + yield* resolvePendingOpenCodeRequest(context, request.id); + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + if (context.emittedTerminalRequestIds.has(request.id)) continue; + context.pendingQuestions.delete(request.id); + context.emittedTerminalRequestIds.add(request.id); + emitUnsafe({ ...base, type: "user-input.resolved", payload: { answers: {} } }); + } + }); + + const clearPendingOpenCodeRequests = Effect.fn("clearPendingOpenCodeRequests")(function* ( + context: OpenCodeSessionContext, + raw: unknown, + ) { + context.pendingRequestRecovery = undefined; + for (const requestId of context.requestRelationRetries.keys()) { + yield* resolvePendingOpenCodeRequest(context, requestId); + } + for (const requestId of context.autoRepliedRequestIds) { + context.emittedTerminalRequestIds.add(requestId); + } + context.autoRepliedRequestIds.clear(); + yield* closePendingOpenCodeRequests( + context, + [...context.pendingPermissions.values()], + [...context.pendingQuestions.values()], + raw, + ); + }); + const scheduleRequestRelationRetry = Effect.fn("scheduleRequestRelationRetry")(function* ( context: OpenCodeSessionContext, event: OpenCodeRoutedRequestEvent, @@ -1854,10 +1970,21 @@ export function makeOpenCodeAdapter( const run = Effect.gen(function* () { let retryCount = 0; while (context.pendingRequestRecovery === recovery) { - const responses = yield* Effect.all({ - permissions: runOpenCodeSdk("permission.list", () => context.client.permission.list()), - questions: runOpenCodeSdk("question.list", () => context.client.question.list()), - }).pipe( + // Only requests pending before the snapshot can be closed by it. + const priorPermissions = [...context.pendingPermissions.values()]; + const priorQuestions = [...context.pendingQuestions.values()]; + const responses = yield* Effect.all( + { + permissions: runOpenCodeSdk("permission.list", (signal) => + context.client.permission.list(undefined, { signal }), + ), + questions: runOpenCodeSdk("question.list", (signal) => + context.client.question.list(undefined, { signal }), + ), + }, + { concurrency: 2 }, + ).pipe( + Effect.timeout("10 seconds"), Effect.match({ onFailure: (cause) => ({ type: "failure" as const, cause }), onSuccess: (value) => ({ type: "success" as const, value }), @@ -1901,6 +2028,14 @@ export function makeOpenCodeAdapter( yield* Effect.sleep(`${delayMs} millis`); continue; } + const permissionIds = new Set(permissions.map((request) => request.id)); + const questionIds = new Set(questions.map((request) => request.id)); + yield* closePendingOpenCodeRequests( + context, + priorPermissions.filter((request) => !permissionIds.has(request.id)), + priorQuestions.filter((request) => !questionIds.has(request.id)), + { type: "pending-requests.recovered" }, + ); yield* Effect.forEach( permissions, (request) => @@ -1970,6 +2105,9 @@ export function makeOpenCodeAdapter( yield* schedulePendingRequestRecovery(context); if (!isFirstConnection) { yield* schedulePromptAdmissionRecovery(context, event); + if (context.activeTurnId !== undefined && context.promptAdmission === undefined) { + yield* scheduleIdleReconciliation(context, context.activeTurnId, event); + } } return; } @@ -2046,6 +2184,7 @@ export function makeOpenCodeAdapter( context.awaitingBusyAfterInterruption) && (event.type === "message.part.delta" || event.type === "message.part.updated" || + event.type === "todo.updated" || (event.type === "message.updated" && event.properties.info.role === "assistant")); if (suppressInterruptedParentOutput) { return; @@ -2125,7 +2264,11 @@ export function makeOpenCodeAdapter( case "message.part.delta": { const existingPart = context.partById.get(event.properties.partID); - if (!existingPart) { + if ( + !existingPart || + (existingPart.type !== "text" && existingPart.type !== "reasoning") || + event.properties.field !== "text" + ) { break; } const role = messageRoleForPart(context, existingPart); @@ -2180,7 +2323,9 @@ export function makeOpenCodeAdapter( if (part.type === "tool") { const itemType = toToolLifecycleItemType(part.tool); const title = - part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; + part.state.status === "running" || part.state.status === "completed" + ? (part.state.title ?? part.tool) + : part.tool; const detail = detailFromToolPart(part); const payload = { itemType, @@ -2194,6 +2339,14 @@ export function makeOpenCodeAdapter( data: { tool: part.tool, state: part.state, + ...(typeof part.state.input.command === "string" + ? { command: part.state.input.command } + : {}), + ...(itemType === "file_change" ? { input: part.state.input } : {}), + ...(part.state.status === "completed" && + (itemType === "command_execution" || itemType === "mcp_tool_call") + ? { result: part.state.output } + : {}), }, }; const runtimeEvent: ProviderRuntimeEvent = { @@ -2224,7 +2377,6 @@ export function makeOpenCodeAdapter( } case "permission.replied": { - context.pendingPermissions.delete(event.properties.requestID); yield* emitTerminalOpenCodeRequest(context, event); break; } @@ -2236,18 +2388,45 @@ export function makeOpenCodeAdapter( case "question.replied": { yield* emitTerminalOpenCodeRequest(context, event); - context.pendingQuestions.delete(event.properties.requestID); break; } case "question.rejected": { - context.pendingQuestions.delete(event.properties.requestID); yield* emitTerminalOpenCodeRequest(context, event); break; } + case "todo.updated": { + if (turnId === undefined) break; + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw: event, + }); + // Session-wide task updates must not reopen progress after a turn ends. + if (context.activeTurnId !== turnId) break; + emitUnsafe({ + ...base, + type: "turn.plan.updated", + payload: { + plan: event.properties.todos + .filter((todo) => todo.status !== "cancelled") + .map((todo) => ({ + step: trimText(todo.content) ?? "Task", + status: + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" + ? "inProgress" + : "pending", + })), + }, + }); + break; + } + case "session.status": { - if (event.properties.status.type === "busy") { + if (event.properties.status.type === "busy" || event.properties.status.type === "retry") { if (turnId === undefined) { break; } @@ -2272,7 +2451,7 @@ export function makeOpenCodeAdapter( })), type: "runtime.warning", payload: { - message: event.properties.status.message, + message: `OpenCode retry ${event.properties.status.attempt}: ${event.properties.status.message}`, detail: event.properties.status, }, }); @@ -2335,6 +2514,7 @@ export function makeOpenCodeAdapter( context.activeAgent = undefined; context.activeVariant = undefined; context.reconcileIdleStatus = false; + yield* schedulePendingRequestRecovery(context); yield* updateProviderSession( context, { @@ -2388,9 +2568,29 @@ export function makeOpenCodeAdapter( // shutdown) and cancels the in-flight `event.subscribe` fetch so // the async iterable unwinds cleanly. const eventsAbortController = new AbortController(); - yield* Scope.addFinalizer( - context.sessionScope, - Effect.sync(() => eventsAbortController.abort()), + let lastStreamError: unknown; + let warnedAboutDisconnect = false; + const streamErrors = yield* Queue.unbounded(); + yield* Scope.addFinalizer(context.sessionScope, Queue.shutdown(streamErrors)); + yield* Stream.fromQueue(streamErrors).pipe( + Stream.runForEach((cause) => + Effect.gen(function* () { + if (warnedAboutDisconnect) return; + warnedAboutDisconnect = true; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + })), + type: "runtime.warning", + payload: { + message: "OpenCode connection lost. Reconnecting.", + detail: openCodeRuntimeErrorDetail(cause), + }, + }); + }), + ), + Effect.forkIn(context.sessionScope), ); // Fibers forked into `context.sessionScope` are interrupted @@ -2399,6 +2599,10 @@ export function makeOpenCodeAdapter( runOpenCodeSdk("event.subscribe", () => context.client.event.subscribe(undefined, { signal: eventsAbortController.signal, + onSseError: (cause) => { + lastStreamError = cause; + Queue.offerUnsafe(streamErrors, cause); + }, }), ), (subscription) => @@ -2410,7 +2614,13 @@ export function makeOpenCodeAdapter( detail: openCodeRuntimeErrorDetail(cause), cause, }), - ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), + ).pipe( + Stream.runForEach((event) => { + if (event.type === "server.connected") lastStreamError = undefined; + if (event.type === "server.connected") warnedAboutDisconnect = false; + return handleSubscribedEvent(context, event); + }), + ), ).pipe( Effect.exit, Effect.flatMap((exit) => @@ -2420,12 +2630,14 @@ export function makeOpenCodeAdapter( if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { return; } - if (Exit.isFailure(exit)) { - yield* emitUnexpectedExit( - context, - openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), - ); - } + yield* emitUnexpectedExit( + context, + Exit.isFailure(exit) + ? openCodeRuntimeErrorDetail(Cause.squash(exit.cause)) + : lastStreamError !== undefined + ? `OpenCode event stream disconnected: ${openCodeRuntimeErrorDetail(lastStreamError)}` + : "OpenCode event stream ended unexpectedly. Send another message to reconnect.", + ); }), ), Effect.forkIn(context.sessionScope), @@ -2444,6 +2656,12 @@ export function makeOpenCodeAdapter( Effect.forkIn(context.sessionScope), ); } + // Scope finalizers run in reverse order. Abort the pending read before + // interrupting the pump, whose iterator.return() waits for that read. + yield* Scope.addFinalizer( + context.sessionScope, + Effect.sync(() => eventsAbortController.abort()), + ); }); const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( @@ -3259,6 +3477,7 @@ export function makeOpenCodeAdapter( } else { context.cancellation = undefined; context.reconcileIdleStatus = true; + yield* clearPendingOpenCodeRequests(context, { type: "session.abort" }); } } yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); @@ -3269,20 +3488,52 @@ export function makeOpenCodeAdapter( "respondToRequest", )(function* (threadId, requestId, decision) { const context = yield* ensureSessionContext(sessions, threadId); - if (!context.pendingPermissions.has(requestId)) { + const request = context.pendingPermissions.get(requestId); + if (!request) { + if (context.emittedTerminalRequestIds.has(requestId)) return; return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "permission.reply", - detail: `Unknown pending permission request: ${requestId}`, + detail: + context.pendingRequestRecovery || context.requestRelationRetries.has(requestId) + ? "OpenCode is still loading this permission request. Try again." + : `Unknown pending permission request: ${requestId}`, }); } - yield* runOpenCodeSdk("permission.reply", () => - context.client.permission.reply({ - requestID: requestId, - reply: toOpenCodePermissionReply(decision), + const reply = toOpenCodePermissionReply(decision); + yield* runOpenCodeSdk("permission.reply", (signal) => + context.client.permission.reply( + { + requestID: requestId, + reply, + }, + { signal }, + ), + ).pipe( + Effect.mapError(toRequestError), + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: "OpenCode permission reply did not complete within 10 seconds.", + }), + ), }), - ).pipe(Effect.mapError(toRequestError)); + ); + yield* resolvePendingOpenCodeRequest(context, requestId); + yield* emitTerminalOpenCodeRequest( + context, + { + id: `reply:${requestId}`, + type: "permission.replied", + properties: { sessionID: request.sessionID, requestID: requestId, reply }, + }, + { type: "permission.reply", requestID: requestId, reply }, + ); }); const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( @@ -3291,19 +3542,54 @@ export function makeOpenCodeAdapter( const context = yield* ensureSessionContext(sessions, threadId); const request = context.pendingQuestions.get(requestId); if (!request) { + if (context.emittedTerminalRequestIds.has(requestId)) return; return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "question.reply", - detail: `Unknown pending user-input request: ${requestId}`, + detail: + context.pendingRequestRecovery || context.requestRelationRetries.has(requestId) + ? "OpenCode is still loading this question. Try again." + : `Unknown pending user-input request: ${requestId}`, }); } - yield* runOpenCodeSdk("question.reply", () => - context.client.question.reply({ - requestID: requestId, - answers: toOpenCodeQuestionAnswers(request, answers), + const questionAnswers = toOpenCodeQuestionAnswers(request, answers); + yield* runOpenCodeSdk("question.reply", (signal) => + context.client.question.reply( + { + requestID: requestId, + answers: questionAnswers, + }, + { signal }, + ), + ).pipe( + Effect.mapError(toRequestError), + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: "OpenCode question reply did not complete within 10 seconds.", + }), + ), }), - ).pipe(Effect.mapError(toRequestError)); + ); + yield* resolvePendingOpenCodeRequest(context, requestId); + yield* emitTerminalOpenCodeRequest( + context, + { + id: `reply:${requestId}`, + type: "question.replied", + properties: { + sessionID: request.sessionID, + requestID: requestId, + answers: questionAnswers, + }, + }, + { type: "question.reply", requestID: requestId }, + ); }); const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( diff --git a/apps/server/src/provider/opencodeRuntime.environment.test.ts b/apps/server/src/provider/opencodeRuntime.environment.test.ts index 584a9d80fb9c..680032de06b4 100644 --- a/apps/server/src/provider/opencodeRuntime.environment.test.ts +++ b/apps/server/src/provider/opencodeRuntime.environment.test.ts @@ -1,12 +1,24 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { describe, expect, it } from "vite-plus/test"; import { + OpenCodeRuntime, OpenCodeRuntimeError, + OpenCodeRuntimeLive, resolveOpenCodeConfigContent, resolveOpenCodeServerPassword, verifyOpenCodeServerVersion, @@ -150,3 +162,78 @@ describe("verifyOpenCodeServerVersion", () => { }).pipe(Effect.provide(TestClock.layer())), ); }); + +describe("OpenCode server output", () => { + effectIt.live( + "drains stdout and stderr after startup so server requests can finish", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const executablePath = yield* HostProcessExecutablePath; + const platform = yield* HostProcessPlatform; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-output-" }); + const isWindows = platform === "win32"; + const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); + const scriptPath = path.join(tempDir, "opencode.mjs"); + + yield* fs.writeFileString( + scriptPath, + `import { createServer } from "node:http"; +const writeOutput = (stream) => new Promise((resolve, reject) => { + stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve()); +}); +const server = createServer(async (request, response) => { + if (request.url.startsWith("/global/health")) { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ healthy: true, version: "1.14.19" })); + return; + } + await Promise.all([writeOutput(process.stdout), writeOutput(process.stderr)]); + response.end("drained"); +}); +server.listen(0, "127.0.0.1", () => { + process.stdout.write("opencode server listening on http://127.0.0.1:" + server.address().port + "\\n"); +}); +`, + ); + yield* fs.writeFileString( + binaryPath, + [ + ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), + isWindows + ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' + : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', + "", + ].join("\n"), + ); + if (!isWindows) { + yield* fs.chmod(binaryPath, 0o755); + } + + const runtime = yield* OpenCodeRuntime; + const server = yield* runtime.startOpenCodeServerProcess({ + binaryPath, + directory: tempDir, + port: 0, + environment: { + ...environment, + T3_TEST_NODE_BINARY: executablePath, + T3_TEST_OPENCODE_SCRIPT: scriptPath, + }, + }); + const response = yield* HttpClient.get(`${server.url}/output`); + + expect(yield* response.text).toBe("drained"); + expect(yield* server.isRunning).toBe(true); + }).pipe( + Effect.scoped, + Effect.provide([ + OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)), + FetchHttpClient.layer, + ]), + ), + 10_000, + ); +}); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 2a878a24ab8e..39ffec7436d4 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -1,12 +1,14 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { OpencodeClient } from "@opencode-ai/sdk/v2"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; import { HostProcessEnvironment, HostProcessExecutablePath, @@ -18,6 +20,44 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { + it.effect("aborts pending SDK requests when inventory loading is interrupted", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const started = yield* Queue.make(); + const aborted = yield* Queue.make(); + const client = createOpencodeClient({ + baseUrl: "http://opencode.test", + fetch: Object.assign( + (input: string | Request | URL) => { + const request = input instanceof Request ? input : new Request(input.toString()); + return new Promise((_resolve, reject) => { + request.signal.addEventListener( + "abort", + () => { + Queue.offerUnsafe(aborted, new URL(request.url).pathname); + reject(request.signal.reason); + }, + { once: true }, + ); + Queue.offerUnsafe(started, undefined); + }); + }, + { preconnect: () => undefined }, + ), + }); + + const inventoryFiber = yield* runtime.loadOpenCodeInventory(client).pipe(Effect.forkChild); + yield* Queue.takeN(started, 3); + yield* Fiber.interrupt(inventoryFiber); + + NodeAssert.deepEqual((yield* Queue.takeAll(aborted)).toSorted(), [ + "/agent", + "/provider", + "/skill", + ]); + }), + ); + it.effect("keeps provider inventory when agent discovery fails", () => Effect.gen(function* () { const runtime = yield* OpenCodeRuntime; diff --git a/apps/server/src/provider/opencodeRuntime.permissions.test.ts b/apps/server/src/provider/opencodeRuntime.permissions.test.ts index be2696d7e100..a6ae1fbe0437 100644 --- a/apps/server/src/provider/opencodeRuntime.permissions.test.ts +++ b/apps/server/src/provider/opencodeRuntime.permissions.test.ts @@ -1,15 +1,21 @@ import * as NodeAssert from "node:assert/strict"; +import * as RegExpUtils from "effect/RegExp"; import { describe, it } from "vite-plus/test"; -import { buildOpenCodePermissionRules } from "./opencodeRuntime.ts"; +import { buildOpenCodePermissionRules, toOpenCodePermissionReply } from "./opencodeRuntime.ts"; function actionFor( runtimeMode: Parameters[0], permission: string, + target = "*", ) { - return buildOpenCodePermissionRules(runtimeMode).find((rule) => rule.permission === permission) - ?.action; + // OpenCode uses the last matching rule. Its wildcards match directory separators. + return buildOpenCodePermissionRules(runtimeMode).findLast( + (rule) => + (rule.permission === "*" || rule.permission === permission) && + new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target), + )?.action; } describe("buildOpenCodePermissionRules", () => { @@ -27,12 +33,38 @@ describe("buildOpenCodePermissionRules", () => { NodeAssert.equal(actionFor("auto", "edit"), "ask"); }); - it("keeps asking for everything else in the auto modes", () => { - for (const runtimeMode of ["auto-accept-edits", "auto"] as const) { + it("allows workspace reads and task updates without asking in supervised modes", () => { + for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { + for (const permission of ["read", "glob", "grep", "lsp", "skill", "todowrite"]) { + NodeAssert.equal(actionFor(runtimeMode, permission, "src/index.ts"), "allow"); + } + } + }); + + it("preserves OpenCode's environment-file approval rules", () => { + for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { + for (const target of [ + ".env", + ".env.local", + "config/service.env", + "config/service.env.local", + ]) { + NodeAssert.equal(actionFor(runtimeMode, "read", target), "ask"); + } + for (const target of [".env.example", "config/service.env.example"]) { + NodeAssert.equal(actionFor(runtimeMode, "read", target), "allow"); + } + } + }); + + it("still asks before commands, network access, external directories and unknown tools", () => { + for (const runtimeMode of ["approval-required", "auto-accept-edits", "auto"] as const) { NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask"); NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "websearch"), "ask"); NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask"); - NodeAssert.equal(actionFor(runtimeMode, "*"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "doom_loop"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "custom_tool"), "ask"); } }); @@ -43,3 +75,15 @@ describe("buildOpenCodePermissionRules", () => { ]); }); }); + +describe("toOpenCodePermissionReply", () => { + it.each([ + ["accept", "once"], + ["acceptForSession", "always"], + ["acceptAlways", "always"], + ["decline", "reject"], + ["cancel", "reject"], + ] as const)("maps %s to %s", (decision, reply) => { + NodeAssert.equal(toOpenCodePermissionReply(decision), reply); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index afd806e5666e..19725d9472ca 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -80,6 +80,7 @@ export function resolveOpenCodeServerPassword( const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; +const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; export interface OpenCodeServerProcess { readonly url: string; @@ -494,8 +495,19 @@ export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): Permissi // reviewer, OpenCode among them, fall back to Supervised for that mode. const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; + // Session rules override OpenCode's agent defaults. Allow reads and task + // updates, but keep its default approval rules for environment files. return [ { permission: "*", pattern: "*", action: "ask" }, + { permission: "read", pattern: "*", action: "allow" }, + { permission: "read", pattern: "*.env", action: "ask" }, + { permission: "read", pattern: "*.env.*", action: "ask" }, + { permission: "read", pattern: "*.env.example", action: "allow" }, + { permission: "glob", pattern: "*", action: "allow" }, + { permission: "grep", pattern: "*", action: "allow" }, + { permission: "lsp", pattern: "*", action: "allow" }, + { permission: "skill", pattern: "*", action: "allow" }, + { permission: "todowrite", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "ask" }, { permission: "edit", pattern: "*", action: editAction }, { permission: "webfetch", pattern: "*", action: "ask" }, @@ -514,6 +526,7 @@ export function toOpenCodePermissionReply( case "accept": return "once"; case "acceptForSession": + case "acceptAlways": return "always"; case "decline": case "cancel": @@ -707,18 +720,24 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); yield* Scope.addFinalizer(runtimeScope, terminateChild); - const stdoutRef = yield* Ref.make(""); - const stderrRef = yield* Ref.make(""); + const stdoutRef = yield* Ref.make(""); + const stderrRef = yield* Ref.make(""); const readyDeferred = yield* Deferred.make(); const setReadyFromStdoutChunk = (chunk: string) => - Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe( - Effect.flatMap((nextStdout) => { - const parsed = parseServerUrlFromOutput(nextStdout); - return parsed - ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) - : Effect.void; - }), + Ref.modify(stdoutRef, (stdout) => { + if (stdout === null) { + return [null, null] as const; + } + const nextStdout = `${stdout}${chunk}`; + return [ + parseServerUrlFromOutput(nextStdout), + nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), + ] as const; + }).pipe( + Effect.flatMap((parsed) => + parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, + ), ); const stdoutFiber = yield* child.stdout.pipe( @@ -729,7 +748,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); const stderrFiber = yield* child.stderr.pipe( Stream.decodeText(), - Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)), + Stream.runForEach((chunk) => + Ref.update(stderrRef, (stderr) => + stderr === null + ? null + : `${stderr}${chunk}`.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), + ), + ), Effect.ignore, Effect.forkIn(runtimeScope), ); @@ -737,8 +762,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const exitFiber = yield* child.exitCode.pipe( Effect.flatMap((code) => Effect.gen(function* () { - const stdout = yield* Ref.get(stdoutRef); - const stderr = yield* Ref.get(stderrRef); + const stdout = (yield* Ref.get(stdoutRef)) ?? ""; + const stderr = (yield* Ref.get(stderrRef)) ?? ""; const exitCode = Number(code); yield* Deferred.fail( readyDeferred, @@ -764,14 +789,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), ); - // Startup-time fibers are no longer needed once ready has resolved (either - // way). The exit fiber is only interrupted on failure; on success it keeps - // the caller's `exitCode` effect observable until the scope closes. - yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore); - yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore); + if (Exit.isFailure(readyExit) || Option.isNone(readyExit.value)) { + yield* Fiber.interruptAll([stdoutFiber, stderrFiber, exitFiber]).pipe(Effect.ignore); + } if (Exit.isFailure(readyExit)) { - yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); const squashed = Cause.squash(readyExit.cause); return yield* ensureRuntimeError( "startOpenCodeServerProcess", @@ -782,13 +804,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const readyOption = readyExit.value; if (Option.isNone(readyOption)) { - yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); return yield* new OpenCodeRuntimeError({ operation: "startOpenCodeServerProcess", detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, }); } + // Keep draining both pipes until the process scope closes. Stopping the + // readers can block OpenCode when its output buffers fill. Startup output + // is no longer needed, so discard later output instead of retaining it. + yield* Ref.set(stdoutRef, null); + yield* Ref.set(stderrRef, null); + const url = readyOption.value; const version = yield* verifyOpenCodeServerVersion( createOpenCodeSdkClient({ @@ -854,7 +881,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }; const loadProviders = (client: OpencodeClient) => - runOpenCodeSdk("provider.list", () => client.provider.list()).pipe( + runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe( Effect.filterMapOrFail( (list) => list.data @@ -870,7 +897,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); const loadAgents = (client: OpencodeClient) => - runOpenCodeSdk("app.agents", () => client.app.agents()).pipe( + runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe( Effect.map((result) => result.data ?? []), Effect.orElseSucceed((): ReadonlyArray => []), ); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 5dade8459e0f..becd8d91f80c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -62,6 +62,37 @@ function makeActivity(overrides: { } describe("derivePendingApprovals", () => { + it.each([{}, { requestType: "unknown" }])( + "exposes legacy OpenCode approvals without a known request kind: %j", + (legacyPayload) => { + const requested = makeActivity({ + kind: "approval.requested", + payload: { requestId: "per-legacy", detail: "*", ...legacyPayload }, + }); + + expect(derivePendingApprovals([requested])).toEqual([ + { + requestId: "per-legacy", + requestKind: "command", + createdAt: requested.createdAt, + detail: "*", + }, + ]); + }, + ); + + it.each(["tool_user_input", "auth_tokens_refresh"])( + "does not turn %s into an approval", + (requestType) => { + const activity = makeActivity({ + kind: "approval.requested", + payload: { requestId: "not-an-approval", requestType }, + }); + + expect(derivePendingApprovals([activity])).toEqual([]); + }, + ); + it("tracks open approvals and removes resolved ones", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -90,7 +121,7 @@ describe("derivePendingApprovals", () => { kind: "approval.requested", summary: "File-change approval requested", tone: "approval", - payload: { requestId: "req-2", requestKind: "file-change" }, + payload: { requestId: "req-2", requestType: "unknown" }, }), ]; @@ -199,7 +230,7 @@ describe("derivePendingApprovals", () => { tone: "approval", payload: { requestId: "req-stale-1", - requestKind: "command", + requestType: "unknown", }, }), makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index c6c7410bebea..b70b2a1ba7f6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -464,10 +464,16 @@ export function derivePendingApprovals( ? payload.options.filter(isProviderApprovalOption) : undefined; - if (activity.kind === "approval.requested" && requestId && requestKind) { + if ( + activity.kind === "approval.requested" && + requestId && + payload?.requestType !== "tool_user_input" && + payload?.requestType !== "auth_tokens_refresh" + ) { openByRequestId.set(requestId, { requestId, - requestKind, + // Older OpenCode requests can have no recognized approval kind. + requestKind: requestKind ?? "command", createdAt: activity.createdAt, ...(detail ? { detail } : {}), ...(appName ? { appName } : {}), diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index a066b038833d..141aa2926add 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -17,14 +17,45 @@ With a server URL, T3 Code connects to that external server and uses only the pa provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server. OpenCode uses this password for HTTP Basic authentication. +## Approvals + +In **Supervised** and **Auto** modes, OpenCode can read normal project files, search files, load +skills, and update its task list without approval. Files such as `.env` and `.env.local` still +require approval. `.env.example` does not. OpenCode does not have an AI approval reviewer, so +**Auto** uses the same permission rules as **Supervised**. + +OpenCode asks before it runs commands, edits files, accesses the web, or accesses directories +outside the workspace. **Auto-accept edits** also permits file edits without approval. +**Full access** permits all these actions. Questions that need your answer can still appear. + +An **Approval** badge means OpenCode needs a decision. Open the thread to see the action and +choose one of these options: + +- **Allow once** permits this request. +- **Allow for workspace** permits matching requests in other OpenCode sessions in the same + workspace. It is not limited to the current thread. +- **Deny** rejects this request. Use **Stop** to stop the whole turn. + +If a connection error prevents the reply, the approval stays available so you can try again. + +## Progress + +T3 Code shows OpenCode's response text and tool results while work runs. The web and desktop apps +also show its task-list progress. A task-list update does not require approval. + +If the OpenCode connection closes unexpectedly, T3 Code shows an error. Send another prompt to +reconnect to the same OpenCode session. + ## Stop a turn When you select **Stop**, T3 Code stops the main OpenCode session and all nested child sessions. T3 Code waits for this cleanup before it marks the turn as stopped or sends the next prompt. It -does not stop unrelated OpenCode sessions. +does not stop unrelated OpenCode sessions. After Stop succeeds, pending approvals and questions +are cleared. -Stop reports an error if OpenCode cannot list or stop a child session. When T3 Code closes an -OpenCode session, it also tries to stop the child sessions, but this teardown is best effort. +Stop reports an error if OpenCode cannot stop the main session or list or stop a child session. +When T3 Code closes an OpenCode session, it also tries to stop the child sessions, but this +teardown is best effort. ## Refresh the model list diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index cc5b1c910539..f3febf5e756d 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -15,6 +15,40 @@ import { } from "./presentation.js"; describe("summarizeToolGroup", () => { + it.each(["command", "file-read", "file-change"])( + "keeps %s approvals out of tool execution counts", + (requestKind) => { + const approvals = [ + { + label: "Approval requested", + sourceActivityKind: "approval.requested", + tone: "info", + requestKind, + }, + { + label: "Approval resolved", + sourceActivityKind: "approval.resolved", + tone: "info", + requestKind, + }, + { + label: "Provider approval response failed", + sourceActivityKind: "provider.approval.respond.failed", + tone: "error", + }, + ] satisfies WorkLogPresentationEntry[]; + + expect( + summarizeToolGroup([ + ...approvals, + { label: "Read", tone: "tool", itemType: "dynamic_tool_call" }, + ]), + ).toBe("Received 3 updates and used 1 tool"); + expect(summarizeToolGroup(approvals)).toBe("Received 3 updates"); + expect(toolGroupSummaryKind(approvals)).toBe("update"); + }, + ); + it("deduplicates named sources ahead of ordinary actions", () => { const source = { key: "browser-use:chrome", name: "Chrome", kind: "integration" as const }; expect( diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index e27a2d318fef..d47f44566452 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -313,6 +313,13 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): } export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction { + if ( + entry.sourceActivityKind === "approval.requested" || + entry.sourceActivityKind === "approval.resolved" || + entry.sourceActivityKind === "provider.approval.respond.failed" + ) { + return "update"; + } if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser"; if ( entry.requestKind === "file-read" || From caa8a0db98f9d32e98a1645caa7f7dd37b14f187 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 06:18:31 -0700 Subject: [PATCH 007/262] fix(desktop): quit immediately on a second shortcut press (#9657) --- apps/desktop/src/window/QuitHold.test.ts | 75 ++++++++++++++----- apps/desktop/src/window/QuitHold.ts | 35 +++++---- apps/web/src/components/QuitHoldOverlay.tsx | 4 +- .../components/settings/SettingsPanels.tsx | 2 +- docs/user/keybindings.md | 13 ++++ 5 files changed, 91 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index c4bf2f34b0a1..58809d8eb14d 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -252,24 +252,31 @@ describe("makeQuitShortcutHandler", () => { expect(harness.notifications).toEqual([]); }); - it("honors a quick double press when both key releases beat their mode reads", async () => { - 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({})); - await harness.send(makeInput({ type: "keyUp" })); - - resolvers[1]?.("double-click"); - await Promise.resolve(); - await Promise.resolve(); - - expect(harness.quit).toHaveBeenCalledTimes(1); - 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 @@ -378,6 +385,38 @@ describe("makeQuitShortcutHandler", () => { expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); + 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_PRESS_MS - 100); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([HOLD_DOWN]); + }); + + it("does not count a released tap after another shortcut interrupts it", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + await harness.send(makeInput({ key: "c" })); + vi.advanceTimersByTime(100); + await harness.send(makeInput({})); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); + }); + it("cancels the hold when another key interrupts it", async () => { const harness = makeHarness(); await harness.send(makeInput({})); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index 4095e3d4354b..a995184ddd70 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -12,7 +12,8 @@ export const QUIT_DOUBLE_PRESS_MS = 500; // 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 fall back to 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. @@ -52,8 +53,8 @@ export function makeQuitShortcutHandler( let lastRepeatAt = 0; let repeatCadenceMs = 0; // Incremented when a press is superseded or explicitly cancelled. A plain - // key release does not invalidate its pending mode read: direct mode and a - // completed second press must still be honored after that read settles. + // 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 = () => { @@ -64,9 +65,9 @@ export function makeQuitShortcutHandler( }; const release = (cancelPendingMode = true, keepDoublePressHint = false) => { + if (cancelPendingMode) generation += 1; if (!holding && !notified) return; const keepHint = keepDoublePressHint && mode === "double-click" && notified; - if (cancelPendingMode) generation += 1; holding = false; armed = false; quitOnRelease = false; @@ -85,6 +86,7 @@ export function makeQuitShortcutHandler( // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. const quitNow = () => { release(); + lastPressAt = 0; options.quit(); }; @@ -138,13 +140,10 @@ export function makeQuitShortcutHandler( // quit shortcut, so it must not cancel an active double-press window. if (key === modifierKey && !input.alt && !input.shift) return; - // Any other key (or an extra modifier) pressed mid-hold breaks the - // gesture; without this the hold timer keeps running through the - // interruption and the next qualifying repeat would quit early. The - // interrupted press also stops counting toward a double press, but only - // here, not in release(), which runs mid-restart on an unseen-release - // re-press and must not wipe that press's own tap timestamp. - if ((holding || notified) && !input.isAutoRepeat) { + // 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(); } @@ -171,6 +170,13 @@ export function makeQuitShortcutHandler( 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; + } + const pressGeneration = generation; holding = true; heldSince = now; @@ -181,13 +187,6 @@ export function makeQuitShortcutHandler( quitNow(); return; } - // Keep a second press as an escape hatch when macOS misses the events - // that would complete a hold. - if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { - quitNow(); - return; - } - if (resolvedMode === "double-click") { const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now); if (remainingMs <= 0) { diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx index 091fa60c2f71..2bca40f5b130 100644 --- a/apps/web/src/components/QuitHoldOverlay.tsx +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -40,7 +40,9 @@ export function QuitHoldOverlay() { if (!visibleMode) return null; const shortcut = isMacPlatform(navigator.platform) ? "โŒ˜Q" : "Ctrl+Q"; const message = - visibleMode === "hold" ? `Hold ${shortcut} to Quit` : `Press ${shortcut} again to Quit`; + visibleMode === "hold" + ? `Hold ${shortcut} or press twice to quit` + : `Press ${shortcut} again to quit`; return (
Date: Fri, 4 Sep 2026 06:23:12 -0700 Subject: [PATCH 008/262] fix(server): update Claude Agent SDK to 0.3.260 (#9135) Co-authored-by: Claude Fable 5.1 --- apps/server/package.json | 2 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 248 +++++++++++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 115 +++++++- pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 1 + 5 files changed, 357 insertions(+), 19 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index ca74368348ea..3e80321d606f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,7 +22,7 @@ "test": "vp test run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.170", + "@anthropic-ai/claude-agent-sdk": "^0.3.260", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 99bb95d83c65..8fcf3cc92134 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2090,6 +2090,177 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("fails a turn when the result carries a give-up terminal_reason", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + // The CLI stamps subtype success with an empty error list when it + // gives up after exhausting API retries; the terminal_reason is the + // only structured failure signal. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + result: "", + errors: [], + stop_reason: null, + terminal_reason: "api_error", + session_id: "sdk-session-api-error", + uuid: "result-api-error", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "runtime.error", + "turn.completed", + ], + ); + + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "failed"); + assert.equal( + turnCompleted.payload.errorMessage, + "Claude gave up after repeated API errors.", + ); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("fails a turn for every dead-turn terminal_reason", () => { + const reasons = [ + "blocking_limit", + "rapid_refill_breaker", + "prompt_too_long", + "image_error", + "model_error", + "malformed_tool_use_exhausted", + "budget_exhausted", + "structured_output_retry_exhausted", + "tool_deferred_unavailable", + "turn_setup_failed", + ]; + // One harness per reason: the fake query settles a single turn. + const runDeadTurn = (reason: string) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const completionFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + result: "", + errors: [], + stop_reason: null, + terminal_reason: reason, + session_id: "sdk-session-dead-turn", + uuid: `result-${reason}`, + } as unknown as SDKMessage); + const completed = yield* Fiber.join(completionFiber); + assert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + assert.equal(completed.value.payload.state, "failed", reason); + assert.ok(completed.value.payload.errorMessage, `${reason} carries an error message`); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }; + return Effect.forEach(reasons, runDeadTurn, { discard: true }); + }); + + it.effect("fails a turn when a success result reports a 529 overload", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: true, + api_error_status: 529, + result: "", + errors: [], + stop_reason: null, + session_id: "sdk-session-overload", + uuid: "result-overload", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "failed"); + assert.equal( + turnCompleted.payload.errorMessage, + "Claude API is overloaded (529). Try again shortly.", + ); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("interruptTurn settles live tasks and closes the provider session", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -3088,7 +3259,36 @@ describe("ClaudeAdapterLive", () => { { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, { type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" }, + { + type: "system", + subtype: "control_request_progress", + request_id: "ctrl-1", + status: "started", + session_id: "session", + uuid: "crp", + }, + { + type: "system", + subtype: "worker_shutting_down", + reason: "host_exit", + session_id: "session", + uuid: "wsd", + }, + { + type: "system", + subtype: "informational", + content: "Loaded 3 skills", + level: "notice", + session_id: "session", + uuid: "info", + }, { type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" }, + { + type: "conversation_reset", + new_conversation_id: "conv-2", + session_id: "session", + uuid: "cr", + }, { type: "system", subtype: "notification", @@ -3111,6 +3311,27 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "notif-high", } as unknown as SDKMessage); + // Warning-level informational notes and refusals without a fallback + // model surface as warning rows too. + harness.query.emit({ + type: "system", + subtype: "informational", + content: "Stop hook prevented continuation", + level: "warning", + prevent_continuation: true, + session_id: "session", + uuid: "info-warn", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "model_refusal_no_fallback", + original_model: "claude-opus-5", + request_id: null, + api_refusal_explanation: "The request was declined by the API.", + content: "Model refused", + session_id: "session", + uuid: "mrnf", + } as unknown as SDKMessage); // session_state_changed maps to the matching session states. for (const [state, uuid] of [ ["running", "ssc-run"], @@ -3141,10 +3362,15 @@ describe("ClaudeAdapterLive", () => { yield* Effect.yieldNow; const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); - // Exactly one warning: the high-priority notification. Nothing else. + // Exactly three warnings: the high-priority notification, the + // warning-level informational note, and the refusal. Nothing else. assert.deepEqual( warnings.map((event) => event.payload.message), - ["context window nearly full"], + [ + "context window nearly full", + "Stop hook prevented continuation", + "The request was declined by the API.", + ], ); const sessionStates = runtimeEvents .filter((event) => event.type === "session.state.changed") @@ -4190,6 +4416,7 @@ describe("ClaudeAdapterLive", () => { { command: "pwd" }, { signal: new AbortController().signal, + requestId: "request-1", suggestions: [ { type: "setMode", @@ -4301,6 +4528,7 @@ describe("ClaudeAdapterLive", () => { { title: "hello" }, { signal: new AbortController().signal, + requestId: "request-2", suggestions: [], toolUseID: "tool-use-mcp-1", }, @@ -4327,6 +4555,7 @@ describe("ClaudeAdapterLive", () => { { command: "git status" }, { signal: new AbortController().signal, + requestId: "request-3", suggestions: [ { type: "addRules", @@ -4385,6 +4614,7 @@ describe("ClaudeAdapterLive", () => { {}, { signal: new AbortController().signal, + requestId: "request-4", toolUseID: "tool-agent-1", }, ); @@ -4409,6 +4639,7 @@ describe("ClaudeAdapterLive", () => { { pattern: "foo", path: "src" }, { signal: new AbortController().signal, + requestId: "request-5", toolUseID: "tool-grep-approval-1", }, ); @@ -4951,6 +5182,7 @@ describe("ClaudeAdapterLive", () => { }, { signal: new AbortController().signal, + requestId: "request-6", toolUseID: "tool-exit-1", }, ); @@ -5073,7 +5305,7 @@ describe("ClaudeAdapterLive", () => { dialogKind: "resume_return", payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 }, }, - { signal: new AbortController().signal }, + { signal: new AbortController().signal, requestId: "request-dialog" }, ); const requested = yield* Stream.runHead(adapter.streamEvents); @@ -5173,6 +5405,7 @@ describe("ClaudeAdapterLive", () => { const permissionPromise = canUseTool("AskUserQuestion", askInput, { signal: new AbortController().signal, + requestId: "request-7", toolUseID: "tool-ask-1", }); @@ -5299,6 +5532,7 @@ describe("ClaudeAdapterLive", () => { const permissionPromise = canUseTool("AskUserQuestion", askInput, { signal: new AbortController().signal, + requestId: "request-8", toolUseID: "tool-ask-2", }); @@ -5364,6 +5598,7 @@ describe("ClaudeAdapterLive", () => { }, { signal: controller.signal, + requestId: "request-9", toolUseID: "tool-ask-abort", }, ); @@ -5439,6 +5674,7 @@ describe("ClaudeAdapterLive", () => { }, { signal: controller.signal, + requestId: "request-10", toolUseID: "tool-ask-pre-aborted", }, ); @@ -5495,7 +5731,11 @@ describe("ClaudeAdapterLive", () => { }, ], }, - { signal: new AbortController().signal, toolUseID: "tool-ask-stop" }, + { + signal: new AbortController().signal, + requestId: "request-stop", + toolUseID: "tool-ask-stop", + }, ); const requestedEvent = yield* Stream.runHead(adapter.streamEvents); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5848bcc457de..75c0283eceb3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -434,10 +434,45 @@ function resultErrorsText(result: SDKResultMessage): string { * so they must never become the error banner. */ function resultUserFacingError(result: SDKResultMessage): string | undefined { - if (result.subtype === "success" || !Array.isArray(result.errors)) { - return undefined; + const listed = + result.subtype === "success" || !Array.isArray(result.errors) + ? undefined + : result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); + if (listed) { + return listed; + } + // Structured failure markers for results whose error list is empty or + // diagnostic-only: an overloaded API (529) and the terminal reasons the + // CLI stamps when it gives up on a turn. + if (isOverloadedResult(result)) { + return "Claude API is overloaded (529). Try again shortly."; + } + switch (result.terminal_reason) { + case "api_error": + return "Claude gave up after repeated API errors."; + case "malformed_tool_use_exhausted": + return "Claude gave up after repeated malformed tool calls."; + case "budget_exhausted": + return "Claude stopped: the turn's token budget was exhausted."; + case "structured_output_retry_exhausted": + return "Claude could not produce the requested structured output."; + case "tool_deferred_unavailable": + return "Claude could not resume a deferred tool call: the tool is no longer available."; + case "turn_setup_failed": + return "Claude could not start the turn."; + case "blocking_limit": + return "Claude stopped: a usage limit blocked the request."; + case "rapid_refill_breaker": + return "Claude stopped: the context refilled too quickly after compaction."; + case "prompt_too_long": + return "Claude stopped: the prompt exceeds the model's context window."; + case "image_error": + return "Claude stopped: an image in the conversation could not be processed."; + case "model_error": + return "Claude stopped: the model returned an error."; + default: + return undefined; } - return result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); } function isInterruptedResult(result: SDKResultMessage): boolean { @@ -1390,7 +1425,43 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( return buildUserMessage({ sdkContent }); }); +/** + * terminal_reason values the CLI classifies as dead turns: the turn died + * rather than finished, even when the result subtype is success and the + * error list is empty. Kept in sync with the messages in + * resultUserFacingError. + */ +const FAILED_TERMINAL_REASONS: ReadonlySet> = + new Set([ + "api_error", + "malformed_tool_use_exhausted", + "budget_exhausted", + "structured_output_retry_exhausted", + "tool_deferred_unavailable", + "turn_setup_failed", + "blocking_limit", + "rapid_refill_breaker", + "prompt_too_long", + "image_error", + "model_error", + ]); + +/** + * The CLI reports repeated 529 overload failures as a success-subtype result + * with api_error_status 529 and an empty error list; the status code is the + * only structured failure signal. + */ +function isOverloadedResult(result: SDKResultMessage): boolean { + return result.subtype === "success" && result.api_error_status === 529; +} + function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { + if ( + isOverloadedResult(result) || + (result.terminal_reason !== undefined && FAILED_TERMINAL_REASONS.has(result.terminal_reason)) + ) { + return "failed"; + } if (result.subtype === "success") { return "completed"; } @@ -3174,15 +3245,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Undeclared-but-real subtypes (absent from the SDK's union, so they can't // be switch cases): consumed intentionally without emitting, otherwise // they fall through to the unknown-subtype warning and surface as spurious - // error rows in client work logs. `background_tasks_changed` is a roster - // snapshot ({tasks: [...]}) โ€” the task_* lifecycle events carry the - // authoritative per-agent data and the typed background_tasks control - // request is the reconciliation source. `vcs_state_changed` + // error rows in client work logs. `vcs_state_changed` // ({kind: commit|push|rebase}) and `code_change_published` // ({provider, url, repo}) are informational CLI notices; the work log // already shows the underlying git/gh tool calls. switch (message.subtype as string) { - case "background_tasks_changed": case "vcs_state_changed": case "code_change_published": return; @@ -3497,12 +3564,39 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; // Inner protocol/UX details with no T3 surface today โ€” consumed // deliberately so they don't masquerade as unknown-subtype warnings. + // `background_tasks_changed` is a roster snapshot ({tasks: [...]}); the + // task_* lifecycle events carry the authoritative per-agent data and + // the typed background_tasks control request is the reconciliation + // source. `control_request_progress` is a liveness heartbeat for an + // in-flight control request. `worker_shutting_down` is a Remote + // Control worker notice; the session close path reports the outcome. case "model_refusal_fallback": case "local_command_output": case "plugin_install": case "commands_changed": case "memory_recall": case "elicitation_complete": + case "background_tasks_changed": + case "control_request_progress": + case "worker_shutting_down": + return; + case "informational": + // Transcript-level CLI notes. Only warnings (e.g. a Stop hook that + // refused continuation) warrant a work-log row; info/notice/ + // suggestion levels are CLI chrome. + if (message.level === "warning") { + yield* emitRuntimeWarning(context, message.content, message); + } + return; + case "model_refusal_no_fallback": + // The API refused the request and no fallback model was available. + // The terminal result reports the failed turn; this row carries the + // refusal explanation the result's error list lacks. + yield* emitRuntimeWarning( + context, + message.api_refusal_explanation?.trim() || message.content, + message, + ); return; case "permission_denied": yield* offerRuntimeEvent({ @@ -3528,7 +3622,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // handled above, so `message` narrows to never here โ€” a new SDK // release adding a subtype fails this typecheck instead of silently // warning at runtime. The runtime fallback still catches undeclared - // wire-only subtypes (like background_tasks_changed used to be). + // wire-only subtypes (like vcs_state_changed). message satisfies never; const unknownMessage = message as never as { subtype: string }; yield* emitRuntimeWarning( @@ -3657,7 +3751,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* handleSdkTelemetryMessage(context, message); return; // Composer prompt suggestions have no T3 surface; consumed deliberately. + // `conversation_reset` announces a CLI-side conversation id swap + // (e.g. /clear); T3 keeps its own thread identity and resume cursor. case "prompt_suggestion": + case "conversation_reset": return; default: { // Exhaustiveness guard (see handleSystemMessage): new SDK top-level diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a7e45ca8575..d69028c36094 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,8 +476,8 @@ importers: apps/server: dependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.170 - version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.260 + version: 0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) @@ -1011,8 +1011,8 @@ packages: '@alchemy.run/node-utils@0.0.5': resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==} - '@anthropic-ai/claude-agent-sdk@0.3.170': - resolution: {integrity: sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==} + '@anthropic-ai/claude-agent-sdk@0.3.260': + resolution: {integrity: sha512-PmABtP4Rwd6l95itQrqzguv6rS9uACqikPB9g8BPeWRKZOpy3xpEOjJLYauof3BFk2wNZnfhr0Ttx8ttcZzq0w==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' @@ -10527,7 +10527,7 @@ snapshots: '@alchemy.run/node-utils@0.0.5': {} - '@anthropic-ai/claude-agent-sdk@0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7d2f9f998617..ee1bd25547f6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -90,6 +90,7 @@ minimumReleaseAgeExclude: - expo-updates@57.0.19 - expo@57.0.18 - expo-widgets@57.0.15 + - "@anthropic-ai/claude-agent-sdk@0.3.260" overrides: # The SDK always receives the user's Claude executable, so its bundled binaries are unused. From 8ac5462920c45cdee63af15b2598909736f2ec84 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 06:24:41 -0700 Subject: [PATCH 009/262] perf(server): stop loading message bodies for thread summaries (#9662) --- .../Layers/ProjectionPipeline.test.ts | 102 +++++++++++++++++- .../Layers/ProjectionPipeline.ts | 26 ++--- .../Layers/ProjectionPendingApprovals.ts | 21 ++++ .../Layers/ProjectionThreadMessages.test.ts | 43 ++++++++ .../Layers/ProjectionThreadMessages.ts | 23 ++++ .../Services/ProjectionPendingApprovals.ts | 5 + .../Services/ProjectionThreadMessages.ts | 5 + 7 files changed, 205 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 9e4f88a5be10..986c078c1c51 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2534,7 +2534,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("maintains shell summary fields across message and activity streams", () => + it.effect("maintains shell summaries without reading message bodies", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2742,6 +2742,106 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { updatedAt: "2026-03-01T08:00:05.000Z", }, ]); + + // Summary refreshes must not decode message bodies or attachment metadata. + yield* sql` + UPDATE projection_thread_messages + SET attachments_json = '{not-json' + WHERE thread_id = 'thread-shell-summary' + `; + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, thread_id, turn_id, status, decision, created_at, resolved_at + ) VALUES + ('summary-pending', 'thread-shell-summary', NULL, 'pending', NULL, + '2026-03-01T08:00:06.000Z', NULL), + ('summary-resolved', 'thread-shell-summary', NULL, 'resolved', 'accept', + '2026-03-01T08:00:06.000Z', '2026-03-01T08:00:06.000Z'), + ('summary-other-thread', 'thread-shell-summary-other', NULL, 'pending', NULL, + '2026-03-01T08:00:06.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, thread_id, turn_id, plan_markdown, implemented_at, + implementation_thread_id, created_at, updated_at + ) VALUES ( + 'summary-plan', 'thread-shell-summary', 'turn-shell-summary-1', '# Plan', NULL, + NULL, '2026-03-01T08:00:06.000Z', '2026-03-01T08:00:06.000Z' + ) + `; + + const refreshEvents = [ + { + type: "thread.session-set", + eventId: EventId.make("evt-shell-summary-7"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-shell-summary"), + occurredAt: "2026-03-01T08:00:07.000Z", + commandId: CommandId.make("cmd-shell-summary-7"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-shell-summary-7"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-shell-summary"), + session: { + threadId: ThreadId.make("thread-shell-summary"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-03-01T08:00:07.000Z", + }, + }, + }, + { + type: "thread.turn-diff-completed", + eventId: EventId.make("evt-shell-summary-8"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-shell-summary"), + occurredAt: "2026-03-01T08:00:08.000Z", + commandId: CommandId.make("cmd-shell-summary-8"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-shell-summary-8"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-shell-summary"), + turnId: TurnId.make("turn-shell-summary-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-shell-summary/1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("message-shell-summary-assistant"), + completedAt: "2026-03-01T08:00:08.000Z", + }, + }, + ] satisfies ReadonlyArray[0]>; + + for (const event of refreshEvents) { + yield* appendAndProject(event); + const summary = yield* sql<{ + readonly latestUserMessageAt: string | null; + readonly pendingApprovalCount: number; + readonly pendingUserInputCount: number; + readonly hasActionableProposedPlan: number; + }>` + SELECT + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan" + FROM projection_threads + WHERE thread_id = 'thread-shell-summary' + `; + assert.deepEqual(summary, [ + { + latestUserMessageAt: "2026-03-01T08:00:02.000Z", + pendingApprovalCount: 1, + pendingUserInputCount: 1, + hasActionableProposedPlan: 1, + }, + ]); + } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ee07f9fb4cdc..ff6c5866fea5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -587,26 +587,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } - const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ - projectionThreadMessageRepository.listByThreadId({ threadId }), - projectionThreadProposedPlanRepository.listByThreadId({ threadId }), - projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), - projectionPendingApprovalRepository.listByThreadId({ threadId }), - ]); - - let latestUserMessageAt: string | null = null; - for (const message of messages) { - if ( - message.role === "user" && - (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) - ) { - latestUserMessageAt = message.createdAt; - } - } + const [latestUserMessageAt, proposedPlans, activities, pendingApprovalCount] = + yield* Effect.all([ + projectionThreadMessageRepository.getLatestUserMessageAt({ threadId }), + projectionThreadProposedPlanRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), + projectionPendingApprovalRepository.countPendingByThreadId({ threadId }), + ]); - const pendingApprovalCount = pendingApprovals.filter( - (approval) => approval.status === "pending", - ).length; const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); const hasActionableProposedPlan = deriveHasActionableProposedPlan({ latestTurnId: existingRow.value.latestTurnId, diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts index 3b159a9e1715..d5631cb0a62c 100644 --- a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts @@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -68,6 +69,16 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { `, }); + const countPendingApprovalRows = SqlSchema.findOne({ + Request: ListProjectionPendingApprovalsInput, + Result: Schema.Struct({ count: Schema.Number }), + execute: ({ threadId }) => sql` + SELECT COUNT(*) AS count + FROM projection_pending_approvals + WHERE thread_id = ${threadId} AND status = 'pending' + `, + }); + const getProjectionPendingApprovalRow = SqlSchema.findOneOption({ Request: GetProjectionPendingApprovalInput, Result: ProjectionPendingApproval, @@ -116,6 +127,15 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { ), ); + const countPendingByThreadId: ProjectionPendingApprovalRepositoryShape["countPendingByThreadId"] = + (input) => + countPendingApprovalRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.countPendingByThreadId:query"), + ), + Effect.map((row) => row.count), + ); + const getByRequestId: ProjectionPendingApprovalRepositoryShape["getByRequestId"] = (input) => getProjectionPendingApprovalRow(input).pipe( Effect.mapError( @@ -142,6 +162,7 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { return { upsert, listByThreadId, + countPendingByThreadId, getByRequestId, deleteByRequestId, deleteByThreadId, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 30e0f42cab89..c8fa16158bae 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,6 +12,49 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { + it.effect("finds the latest user-message time within one thread", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-latest-user-message"); + assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + + const messages = [ + { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, + { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, + { role: "assistant", createdAt: "2026-02-28T19:05:03.000Z" }, + { role: "system", createdAt: "2026-02-28T19:05:04.000Z" }, + ] as const; + for (const [index, message] of messages.entries()) { + yield* repository.upsert({ + messageId: MessageId.make(`latest-user-message-${index}`), + threadId, + turnId: null, + ...message, + text: "Message body", + isStreaming: false, + updatedAt: "2026-02-28T19:06:00.000Z", + }); + } + yield* repository.upsert({ + messageId: MessageId.make("latest-user-message-other-thread"), + threadId: ThreadId.make("thread-latest-user-message-other"), + turnId: null, + role: "user", + text: "Other thread", + isStreaming: false, + createdAt: "2026-02-28T19:05:05.000Z", + updatedAt: "2026-02-28T19:05:05.000Z", + }); + + assert.strictEqual( + yield* repository.getLatestUserMessageAt({ threadId }), + "2026-02-28T19:05:02.000Z", + ); + yield* repository.deleteByThreadId({ threadId }); + assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + }), + ); + it.effect("appends streaming text and applies attachment updates", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 85e854dc6606..ce28e11b8601 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -182,6 +182,18 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + const getLatestUserMessageAtRow = SqlSchema.findOne({ + Request: ListProjectionThreadMessagesInput, + Result: Schema.Struct({ + latestUserMessageAt: Schema.NullOr(ProjectionThreadMessage.fields.createdAt), + }), + execute: ({ threadId }) => sql` + SELECT MAX(created_at) AS "latestUserMessageAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} AND role = 'user' + `, + }); + const deleteProjectionThreadMessageRows = SqlSchema.void({ Request: DeleteProjectionThreadMessagesInput, execute: ({ threadId }) => @@ -219,6 +231,16 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map((rows) => rows.map(toProjectionThreadMessage)), ); + const getLatestUserMessageAt: ProjectionThreadMessageRepositoryShape["getLatestUserMessageAt"] = ( + input, + ) => + getLatestUserMessageAtRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.getLatestUserMessageAt:query"), + ), + Effect.map((row) => row.latestUserMessageAt), + ); + const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadMessageRows(input).pipe( Effect.mapError( @@ -231,6 +253,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { appendStreaming, getByMessageId, listByThreadId, + getLatestUserMessageAt, deleteByThreadId, } satisfies ProjectionThreadMessageRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts index 40b0d1ae03b6..43a829de6390 100644 --- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts @@ -69,6 +69,11 @@ export interface ProjectionPendingApprovalRepositoryShape { input: ListProjectionPendingApprovalsInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** Count pending approvals without loading resolved request history. */ + readonly countPendingByThreadId: ( + input: ListProjectionPendingApprovalsInput, + ) => Effect.Effect; + /** * Read a pending approval row by request id. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index 17b659a2f8da..a41737564382 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -90,6 +90,11 @@ export interface ProjectionThreadMessageRepositoryShape { input: ListProjectionThreadMessagesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** Read the latest user-message timestamp without loading message bodies. */ + readonly getLatestUserMessageAt: ( + input: ListProjectionThreadMessagesInput, + ) => Effect.Effect; + /** * Delete projected thread messages by thread. */ From cccd7e3c885065e925f559c5708378cdb3b51eb3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 06:27:42 -0700 Subject: [PATCH 010/262] perf(web): speed up terminal snapshots (#9663) --- apps/web/src/terminal/ghostty/core.test.ts | 116 ++++++++++++++++++++- apps/web/src/terminal/ghostty/core.ts | 47 ++++++--- apps/web/src/terminal/ghostty/runtime.ts | 38 ++++--- 3 files changed, 169 insertions(+), 32 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 8048d221178c..6f3254359f10 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { ghosttyCellText } from "./core"; +import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core"; +import { loadGhosttyRuntime } from "./runtime"; + +vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({ + default: (await import("./vendor/ghostty-vt.wasm?inline")).default, +})); +vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({ + default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default, +})); function codepointView(codepoints: ReadonlyArray): DataView { const view = new DataView(new ArrayBuffer(codepoints.length * 4)); @@ -30,7 +38,111 @@ describe("ghosttyCellText", () => { expect([...text]).toEqual(["\u{1F642}", "\u{20E3}"]); }); + it("converts a single astral codepoint", () => { + expect(ghosttyCellText(codepointView([0x1f642]), 1)).toBe("๐Ÿ™‚"); + }); + it("returns an empty string for empty cells", () => { expect(ghosttyCellText(codepointView([]), 0)).toBe(""); }); }); + +describe("GhosttyTerminalCore snapshots", () => { + const cores = new Set(); + + async function createCore() { + const core = await GhosttyTerminalCore.create( + 12, + 3, + 8, + 16, + { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + () => {}, + ); + cores.add(core); + return core; + } + + afterEach(() => { + for (const core of cores) core.dispose(); + cores.clear(); + vi.restoreAllMocks(); + }); + + it("preserves styles, wide cells, and selection after shared memory grows", async () => { + const core = await createCore(); + const runtime = await loadGhosttyRuntime(); + const grapheme = `e${"\u0301".repeat(64)}`; + core.write(`\x1b[1;3;4;8;9;53;38;2;123;45;67;48;2;9;8;7m${grapheme}\x1b[0m็•Œ๐Ÿ™‚`); + const cells = core.snapshot().rowData[0]!.cells; + expect(cells[0]).toEqual({ + text: grapheme, + wide: 0, + foreground: { r: 123, g: 45, b: 67 }, + background: { r: 9, g: 8, b: 7 }, + bold: true, + italic: true, + invisible: true, + strikethrough: true, + overline: true, + underline: true, + selected: false, + }); + expect(cells.slice(1, 5).map(({ text, wide }) => ({ text, wide }))).toEqual([ + { text: "็•Œ", wide: 0 }, + { text: "", wide: GHOSTTY_CELL_WIDE.spacerTail }, + { text: "๐Ÿ™‚", wide: 0 }, + { text: "", wide: GHOSTTY_CELL_WIDE.spacerTail }, + ]); + + runtime.memory.grow(1); + core.setSelection({ x: 0, y: 0 }, { x: 2, y: 0 }); + expect(core.snapshot().rowData[0]!.cells[0]).toEqual({ ...cells[0], selected: true }); + core.clearSelection(); + expect(core.snapshot().rowData[0]!.cells[0]).toEqual(cells[0]); + + core.resetAndWrite("\x1b[2;7;38;2;40;100;200;48;2;12;34;56mC\x1b[0m"); + expect(core.snapshot().rowData[0]!.cells[0]).toMatchObject({ + text: "C", + foreground: { r: 22, g: 59, b: 112 }, + background: { r: 40, g: 100, b: 200 }, + bold: false, + underline: false, + selected: false, + }); + }); + + it("reuses a grown grapheme buffer and releases it on disposal", async () => { + const core = await createCore(); + const runtime = await loadGhosttyRuntime(); + core.write("ASCII"); + core.snapshot(); + + const grapheme = `z${"\u0301".repeat(256)}`; + core.resetAndWrite(`${grapheme}X`); + const alloc = vi.spyOn(runtime, "alloc"); + const free = vi.spyOn(runtime, "free"); + expect( + core + .snapshot() + .rowData[0]!.cells.slice(0, 2) + .map((cell) => cell.text), + ).toEqual([grapheme, "X"]); + expect(alloc).toHaveBeenCalledTimes(1); + const allocation = alloc.mock.results[0]!; + if (allocation.type !== "return") throw new Error("Grapheme allocation did not return"); + const buffer = allocation.value; + const capacity = alloc.mock.calls[0]![0]; + + core.write("\rQ\u0301"); + alloc.mockClear(); + expect(core.snapshot().rowData[0]!.cells[0]!.text).toBe("Q\u0301"); + expect(alloc).not.toHaveBeenCalled(); + core.dispose(); + expect(free).toHaveBeenCalledWith(buffer, capacity); + }); +}); diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index 6f6cbbe0a888..d01e20529d45 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -174,6 +174,7 @@ function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { * every codepoint into String.fromCodePoint at once. */ export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string { + if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(0, true)); const CHUNK_SIZE = 4_096; let text = ""; for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) { @@ -206,6 +207,8 @@ export class GhosttyTerminalCore { private ptyWriterId = 0; private ptyWriter: ((data: string) => void) | null = null; private scratch = 0; + private graphemes = 0; + private graphemeCapacity = 0; private style = 0; private scrollbar = 0; private rows: GhosttyRow[] = []; @@ -878,6 +881,7 @@ export class GhosttyTerminalCore { this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size); } if (this.scratch) this.runtime.free(this.scratch, 16); + if (this.graphemes) this.runtime.free(this.graphemes, this.graphemeCapacity); for (const slot of [ this.mouseEventSlot, this.mouseEncoderSlot, @@ -944,6 +948,7 @@ export class GhosttyTerminalCore { ), ); const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); + const { size: styleSize, fields: styleFields } = this.runtime.layout("GhosttyStyle"); const cells: GhosttyCell[] = []; while ( cells.length < cols && @@ -951,7 +956,6 @@ export class GhosttyTerminalCore { ) { let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground); let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground); - const styleSize = this.runtime.layout("GhosttyStyle").size; this.runtime.bytes(this.style, styleSize).fill(0); this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); this.runtime.call( @@ -960,30 +964,30 @@ export class GhosttyTerminalCore { CELL_DATA.style, this.style, ); - const inverse = this.runtime.readField(this.style, "GhosttyStyle", "inverse") !== 0; - if (inverse) [foreground, background] = [background, foreground]; - if (this.runtime.readField(this.style, "GhosttyStyle", "faint") !== 0) { - foreground = blend(foreground, background); - } const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength); let text = ""; if (graphemeLength > 0) { const bufferSize = graphemeLength * 4; - const codepoints = this.runtime.alloc(bufferSize); + if (bufferSize > this.graphemeCapacity) { + const capacity = Math.max(bufferSize, this.graphemeCapacity * 2); + const buffer = this.runtime.alloc(capacity); + this.runtime.free(this.graphemes, this.graphemeCapacity); + this.graphemes = buffer; + this.graphemeCapacity = capacity; + } if ( this.runtime.call( "ghostty_render_state_row_cells_get", cellsIterator, CELL_DATA.graphemes, - codepoints, + this.graphemes, ) === GHOSTTY_SUCCESS ) { // Read through a DataView: the byte-array allocator guarantees no // 4-byte alignment, which a Uint32Array view would require. - const codepointView = this.runtime.view(codepoints, bufferSize); + const codepointView = this.runtime.view(this.graphemes, bufferSize); text = ghosttyCellText(codepointView, graphemeLength); } - this.runtime.free(codepoints, bufferSize); } let wide = 0; if (text.length === 0 && cells.at(-1)?.text.length) { @@ -1004,18 +1008,27 @@ export class GhosttyTerminalCore { ); wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); } + const selected = this.getCellBool(cellsIterator, CELL_DATA.selected); + // Read the style after allocation and ABI calls, which can grow WASM memory. + const styleView = this.runtime.view(this.style, styleSize); + if (styleView.getUint8(styleFields.inverse!.offset) !== 0) { + [foreground, background] = [background, foreground]; + } + if (styleView.getUint8(styleFields.faint!.offset) !== 0) { + foreground = blend(foreground, background); + } cells.push({ text, wide, foreground, background, - bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0, - italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0, - invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0, - strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0, - overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0, - underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0, - selected: this.getCellBool(cellsIterator, CELL_DATA.selected), + bold: styleView.getUint8(styleFields.bold!.offset) !== 0, + italic: styleView.getUint8(styleFields.italic!.offset) !== 0, + invisible: styleView.getUint8(styleFields.invisible!.offset) !== 0, + strikethrough: styleView.getUint8(styleFields.strikethrough!.offset) !== 0, + overline: styleView.getUint8(styleFields.overline!.offset) !== 0, + underline: styleView.getInt32(styleFields.underline!.offset, true) !== 0, + selected, }); } while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground)); diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts index aca82e7cc3c0..976900fa6d84 100644 --- a/apps/web/src/terminal/ghostty/runtime.ts +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -23,6 +23,7 @@ export class GhosttyRuntime { readonly memory: WebAssembly.Memory; readonly layouts: TypeLayouts; private readonly exports: WebAssembly.Exports; + private memoryView: DataView; private readonly ptyWriters = new Map void>(); private nextPtyWriterId = 1; private writePtyFunctionIndex = 0; @@ -34,6 +35,7 @@ export class GhosttyRuntime { throw new Error("libghostty-vt did not export WebAssembly memory"); } this.memory = memory; + this.memoryView = new DataView(memory.buffer); const jsonPointer = this.call("ghostty_type_json"); const bytes = new Uint8Array(memory.buffer); let end = jsonPointer; @@ -104,7 +106,7 @@ export class GhosttyRuntime { } readPointer(slot: number): number { - return new DataView(this.memory.buffer).getUint32(slot, true); + return this.currentMemoryView().getUint32(slot, true); } attachPtyWriter(terminal: number, writer: (data: string) => void): number { @@ -132,27 +134,36 @@ export class GhosttyRuntime { return new Uint8Array(this.memory.buffer, pointer, size); } + /** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */ + private currentMemoryView(): DataView { + if (this.memoryView.buffer !== this.memory.buffer) { + this.memoryView = new DataView(this.memory.buffer); + } + return this.memoryView; + } + setField(pointer: number, structName: string, fieldName: string, value: number): void { const field = this.layout(structName).fields[fieldName]; if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); - const view = this.view(pointer + field.offset, field.size); + const view = this.currentMemoryView(); + const offset = pointer + field.offset; switch (field.type) { case "bool": case "u8": - view.setUint8(0, value); + view.setUint8(offset, value); return; case "u16": - view.setUint16(0, value, true); + view.setUint16(offset, value, true); return; case "i32": - view.setInt32(0, value, true); + view.setInt32(offset, value, true); return; case "u32": case "enum": - view.setUint32(0, value, true); + view.setUint32(offset, value, true); return; case "u64": - view.setBigUint64(0, BigInt(value), true); + view.setBigUint64(offset, BigInt(value), true); return; default: throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); @@ -162,20 +173,21 @@ export class GhosttyRuntime { readField(pointer: number, structName: string, fieldName: string): number { const field = this.layout(structName).fields[fieldName]; if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); - const view = this.view(pointer + field.offset, field.size); + const view = this.currentMemoryView(); + const offset = pointer + field.offset; switch (field.type) { case "bool": case "u8": - return view.getUint8(0); + return view.getUint8(offset); case "u16": - return view.getUint16(0, true); + return view.getUint16(offset, true); case "i32": - return view.getInt32(0, true); + return view.getInt32(offset, true); case "u32": case "enum": - return view.getUint32(0, true); + return view.getUint32(offset, true); case "u64": - return Number(view.getBigUint64(0, true)); + return Number(view.getBigUint64(offset, true)); default: throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); } From 082cab224624eb3a6cd494df3719c59014fb0c99 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 06:32:44 -0700 Subject: [PATCH 011/262] fix(web): show machine icons in the environment picker (#9668) --- apps/web/src/components/CommandPalette.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c6be46498164..06813228b8e4 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -30,6 +30,7 @@ import { import { type DesktopWslState, type EnvironmentId, + type EnvironmentMachineKind, type FilesystemBrowseResult, type ProjectId, type SourceControlDiscoveryResult, @@ -206,6 +207,7 @@ function getEnvironmentBrowsePlatform(os: string | null | undefined): string { interface AddProjectEnvironmentOption { readonly environmentId: EnvironmentId; readonly label: string; + readonly machine: EnvironmentMachineKind; readonly isPrimary: boolean; readonly isConnected: boolean; readonly status: string; @@ -833,6 +835,7 @@ function OpenCommandPaletteDialog(props: { runtimeLabel: environment.label, }), isPrimary, + machine: resolveEnvironmentMachineKind(environment.serverConfig), isConnected: canCreateProjectInEnvironment(environment.connection.phase), status: connectionStatusText(environment.connection), }; @@ -1478,7 +1481,7 @@ function OpenCommandPaletteDialog(props: { : option.environmentId : option.status, disabled: !option.isConnected, - icon: , + icon: , keepOpen: true, run: async () => { startAddProjectSourceSelection(option.environmentId); From 3b6be3ef4daa848e10c095d8a088064ac836be7f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 06:50:51 -0700 Subject: [PATCH 012/262] perf(mobile): bound diff syntax highlighting work (#9673) --- .../diffs/nativeReviewDiffHighlighter.test.ts | 153 +++++++++++++++++- .../diffs/nativeReviewDiffHighlighter.ts | 113 +++++++++++-- 2 files changed, 248 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts index 9e1480d1de93..00679afa4a63 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -1,8 +1,47 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { NativeReviewDiffRow } from "./nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "./nativeReviewDiffTypes"; -import { highlightNativeReviewDiffVisibleRows } from "./nativeReviewDiffHighlighter"; +import { + highlightNativeReviewDiffVisibleRows, + streamNativeReviewDiffTokens, + type NativeReviewDiffTokenChunk, +} from "./nativeReviewDiffHighlighter"; + +const tokenization = vi.hoisted(() => ({ + calls: [] as string[], + afterCall: undefined as (() => void) | undefined, +})); + +vi.mock("@shikijs/core", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createHighlighterCore: async (...args: Parameters) => { + const highlighter = await original.createHighlighterCore(...args); + return { + ...highlighter, + codeToTokensBase: (...input: Parameters) => { + tokenization.calls.push(input[0]); + const result = highlighter.codeToTokensBase(...input); + tokenization.afterCall?.(); + return result; + }, + }; + }, + }; +}); + +// Exercise the native entry path without requiring an iOS or Android runtime. +vi.mock("react-native-shiki-engine", async () => { + const { createJavaScriptRegexEngine } = await import("@shikijs/engine-javascript"); + return { isNativeEngineAvailable: () => true, createNativeEngine: createJavaScriptRegexEngine }; +}); + +afterEach(() => { + tokenization.calls = []; + tokenization.afterCall = undefined; +}); const TYPESCRIPT_FILE: NativeReviewDiffFile = { id: "file-1", @@ -187,3 +226,113 @@ describe("highlightNativeReviewDiffVisibleRows", () => { ); }); }); + +describe.each(["native", "javascript"] as const)("%s highlighting budgets", (engine) => { + const highlightRows = (rows: ReadonlyArray, signal?: AbortSignal) => + highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine, + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + overscanRows: 0, + signal, + }); + + const line = (id: number, content: string) => + makeLine({ + id: `line-${id}`, + content, + change: "add", + oldLineNumber: null, + newLineNumber: id, + }); + + it("still highlights a line at the length limit", async () => { + const content = `// ${"x".repeat(997)}`; + const result = await highlightRows([line(1, content)]); + + expect(tokenization.calls).toEqual([content]); + expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); + }); + + it("keeps long lines and unknown following syntax plain until the next hunk", async () => { + const longLine = `${"x".repeat(1_001)} /*`; + const rows = [ + line(1, "export const before = 1;"), + line(2, longLine), + { kind: "comment", id: "note", commentText: "Check this", fileId: TYPESCRIPT_FILE.id }, + line(3, "inside the comment */"), + makeHunk("next-hunk"), + line(100, "export const after = 2;"), + ] satisfies ReadonlyArray; + const result = await highlightRows(rows); + + expect(result.engine).toBe(engine); + expect(Object.keys(result.tokensByRowId)).toEqual(["line-1", "line-2", "line-3", "line-100"]); + expect(result.tokensByRowId["line-2"]).toEqual([ + { content: longLine, color: null, fontStyle: null }, + ]); + expect(result.tokensByRowId["line-3"]).toEqual([ + { content: "inside the comment */", color: null, fontStyle: null }, + ]); + expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); + expect(result.tokensByRowId["line-100"]?.some((token) => token.color !== null)).toBe(true); + expect(tokenization.calls.some((code) => code.includes(longLine))).toBe(false); + }); + + it("preserves multiline grammar and row mapping across character-limited batches", async () => { + const opening = line(1, "const message = `open"); + const body = Array.from({ length: 40 }, (_, index) => line(index + 2, "inside ".repeat(45))); + const closing = line(42, "closed`; "); + const trailing = line(43, "export const after = 2;"); + const result = await highlightRows([opening, ...body, closing, trailing]); + const calls = [...tokenization.calls]; + const expected = await highlightRows([ + opening, + { ...closing, newLineNumber: 2 }, + { ...trailing, newLineNumber: 3 }, + ]); + + expect(calls.length).toBeGreaterThan(1); + expect(calls.every((code) => code.length <= 8_000)).toBe(true); + expect(result.rowCount).toBe(43); + for (const row of [opening, ...body, closing, trailing]) { + expect(result.tokensByRowId[row.id]?.map((token) => token.content).join("")).toBe( + row.content, + ); + } + expect(result.tokensByRowId[closing.id]).toEqual(expected.tokensByRowId[closing.id]); + expect(result.tokensByRowId[trailing.id]).toEqual(expected.tokensByRowId[trailing.id]); + }); + + it("stops before later batches and publishes no partial result after cancellation", async () => { + const controller = new AbortController(); + tokenization.afterCall = () => controller.abort(); + const rows = Array.from({ length: 30 }, (_, index) => line(index + 1, `// ${"x".repeat(400)}`)); + + const result = await highlightRows(rows, controller.signal); + + expect(tokenization.calls).toHaveLength(1); + expect(result.rowCount).toBe(0); + expect(result.tokensByRowId).toEqual({}); + }); + + it("applies the same long-line guard to streamed token chunks", async () => { + const content = "x".repeat(10_000); + const chunks: NativeReviewDiffTokenChunk[] = []; + + await streamNativeReviewDiffTokens({ + rows: [line(1, content)], + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine, + onChunk: (chunk) => chunks.push(chunk), + }); + + expect(chunks).toHaveLength(1); + expect(chunks[0]?.tokensByRowId["line-1"]).toEqual([{ content, color: null, fontStyle: null }]); + expect(tokenization.calls).toHaveLength(0); + }); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 14158e61c7d6..383e1e73a85f 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -1,4 +1,4 @@ -import { createHighlighterCore, type HighlighterCore } from "@shikijs/core"; +import { createHighlighterCore, type GrammarState, type HighlighterCore } from "@shikijs/core"; import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript"; import bashLanguage from "@shikijs/langs/bash"; import diffLanguage from "@shikijs/langs/diff"; @@ -46,8 +46,12 @@ export interface NativeReviewDiffHighlighterHandle { readonly engine: NativeReviewDiffHighlightEngine; readonly tokenize: ( code: string, - options: { readonly lang: NativeReviewDiffLanguage; readonly theme: string }, - ) => ReadonlyArray>; + options: { + readonly lang: NativeReviewDiffLanguage; + readonly theme: string; + readonly signal?: AbortSignal; + }, + ) => Promise>>; } interface NativeReviewDiffLineRow extends NativeReviewDiffRow { @@ -97,6 +101,8 @@ export interface HighlightNativeReviewDiffVisibleRowsInput { const NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE = 500; const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160; const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360; +const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH = 1_000; +const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_CHARACTERS = 8_000; const NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME = { dark: "t3-pierre-dark", @@ -226,6 +232,63 @@ function normalizeTokens( ); } +function createHighlighterHandle( + highlighter: HighlighterCore, + engine: NativeReviewDiffHighlightEngine, +): NativeReviewDiffHighlighterHandle { + return { + engine, + async tokenize(code, { lang, theme, signal }) { + const lines = code.split("\n"); + const highlighted: Array> = []; + let grammarState: GrammarState | undefined; + let start = 0; + + while (start < lines.length) { + if (signal?.aborted) return []; + + // Skipping this line leaves its ending grammar state unknown. Keep the + // rest of this contiguous segment plain instead of guessing its syntax. + if (lines[start]!.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH) { + highlighted.push( + ...lines + .slice(start) + .map((content) => [{ content: content || " ", color: null, fontStyle: null }]), + ); + break; + } + + let end = start; + let characters = 0; + while (end < lines.length) { + const length = lines[end]!.length; + const nextCharacters = characters + length + (end > start ? 1 : 0); + if ( + length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH || + nextCharacters > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_CHARACTERS + ) { + break; + } + characters = nextCharacters; + end += 1; + } + + const tokens = highlighter.codeToTokensBase(lines.slice(start, end).join("\n"), { + lang, + theme, + grammarState, + }); + grammarState = highlighter.getLastGrammarState(tokens); + highlighted.push(...normalizeTokens(tokens)); + start = end; + if (start < lines.length) await waitForNextFrame(); + } + + return signal?.aborted ? [] : highlighted; + }, + }; +} + async function createNativeReviewDiffHighlighter(): Promise { const nativeEngineModule = await import("react-native-shiki-engine"); if (!nativeEngineModule.isNativeEngineAvailable()) { @@ -238,10 +301,7 @@ async function createNativeReviewDiffHighlighter(): Promise normalizeTokens(highlighter.codeToTokensBase(code, options)), - }; + return createHighlighterHandle(highlighter, "native"); } async function createJavascriptReviewDiffHighlighter(): Promise { @@ -251,10 +311,7 @@ async function createJavascriptReviewDiffHighlighter(): Promise normalizeTokens(highlighter.codeToTokensBase(code, options)), - }; + return createHighlighterHandle(highlighter, "javascript"); } export async function getNativeReviewDiffHighlighter( @@ -438,8 +495,9 @@ export async function highlightNativeReviewDiffVisibleRows( const tokensByRowId: Record> = {}; let segmentRows: IndexedNativeReviewDiffLineRow[] = []; let segmentFile: NativeReviewDiffFile | undefined; + let charactersSinceYield = 0; - const flushSegment = () => { + const flushSegment = async () => { if (!segmentFile || segmentRows.length === 0 || input.signal?.aborted) { segmentRows = []; segmentFile = undefined; @@ -447,7 +505,20 @@ export async function highlightNativeReviewDiffVisibleRows( } const code = segmentRows.map(({ row }) => row.content).join("\n"); - const tokenLines = highlighter.tokenize(code, { lang: segmentFile.language, theme }); + if ( + charactersSinceYield > 0 && + charactersSinceYield + code.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_CHARACTERS + ) { + await waitForNextFrame(); + charactersSinceYield = 0; + if (input.signal?.aborted) return; + } + const tokenLines = await highlighter.tokenize(code, { + lang: segmentFile.language, + theme, + signal: input.signal, + }); + charactersSinceYield += code.length; segmentRows.forEach(({ row }, rowIndex) => { tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); }); @@ -456,6 +527,7 @@ export async function highlightNativeReviewDiffVisibleRows( }; for (const selectedRow of selectedRows) { + if (input.signal?.aborted) break; const { row } = selectedRow; const file = fileMap.get(row.fileId); if (!file) { @@ -469,13 +541,17 @@ export async function highlightNativeReviewDiffVisibleRows( (previousRow !== undefined && !canShareGrammarContext(previousRow, selectedRow, input.rows))) ) { - flushSegment(); + await flushSegment(); } segmentFile = file; segmentRows.push(selectedRow); } - flushSegment(); + await flushSegment(); + + if (input.signal?.aborted) { + return { engine: highlighter.engine, tokensByRowId: {}, rowCount: 0, durationMs: 0 }; + } return { engine: highlighter.engine, @@ -504,7 +580,12 @@ export async function streamNativeReviewDiffTokens( const startedAt = performance.now(); const chunkRows = fileRows.slice(startIndex, startIndex + chunkSize); const code = chunkRows.map((row) => row.content).join("\n"); - const tokenLines = highlighter.tokenize(code, { lang: file.language, theme }); + const tokenLines = await highlighter.tokenize(code, { + lang: file.language, + theme, + signal: input.signal, + }); + if (input.signal?.aborted) return highlighter.engine; const tokensByRowId: Record> = {}; chunkRows.forEach((row, rowIndex) => { From 887ece307131bdc853cc10f3b82067dee77c4ecf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 07:01:57 -0700 Subject: [PATCH 013/262] perf(web): keep Markdown mounted during streaming (#9677) --- apps/web/package.json | 2 + apps/web/src/components/ChatMarkdown.test.tsx | 170 ++++ apps/web/src/components/ChatMarkdown.tsx | 933 ++++++++++-------- .../src/components/chat/MessagesTimeline.tsx | 5 +- pnpm-lock.yaml | 17 + 5 files changed, 691 insertions(+), 436 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 283024eca095..4c9396cc725b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -61,10 +61,12 @@ "@types/culori": "^4.0.1", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", + "@types/react-test-renderer": "19.1.0", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", + "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", "vite": "catalog:", "vite-plus": "catalog:" diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index c3e536d70ae2..18a6c5115eeb 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,9 +1,38 @@ import { EnvironmentId } from "@t3tools/contracts"; +import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { create, type ReactTestRenderer } from "react-test-renderer"; import { describe, expect, it, vi } from "vite-plus/test"; +import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { Button } from "./ui/button"; +import { setMarkdownTaskChecked } from "./files/filePreviewMode"; + vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../hooks/useSettings", async (importOriginal) => { + const actual = await importOriginal(); + const settings = actual.getClientSettings(); + return { + ...actual, + useClientSettings: (select?: (value: typeof settings) => unknown) => + select ? select(settings) : settings, + }; +}); +vi.mock("./ui/tooltip", async () => { + const { cloneElement, isValidElement } = await import("react"); + return { + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger({ + render, + children, + }: ComponentProps) { + if (!isValidElement(render)) return <>{children}; + return children === undefined ? render : cloneElement(render, undefined, children); + }, + TooltipPopup: () => null, + }; +}); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); vi.mock("../state/session", async (importOriginal) => ({ @@ -35,6 +64,147 @@ import ChatMarkdown, { shouldUseMarkdownFileBrowserPrimaryAction, } from "./ChatMarkdown"; +function codeButton(renderer: ReactTestRenderer, label: string) { + const button = renderer.root + .findAllByType(Button) + .find((instance) => instance.props["aria-label"] === label); + if (!button) throw new Error(`Missing code button: ${label}`); + return button.props as ComponentProps; +} + +describe("ChatMarkdown streaming", () => { + it("preserves code controls and details without highlighting an unchanged fence again", async () => { + const highlighter = await getSyntaxHighlighterPromise("text"); + const highlight = vi.spyOn(highlighter, "codeToHtml"); + const writeText = vi.fn(async (_text: string) => {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let renderer: ReactTestRenderer | undefined; + const text = [ + "```text", + "First code block", + "```", + "", + "
More", + "", + "Details content", + "", + "
", + "", + "Streaming reply", + ].join("\n"); + + try { + await act(async () => { + renderer = create(); + }); + const mounted = renderer!; + const codeBlock = mounted.root.findByProps({ "data-language": "text" }); + const initialWrap = codeBlock.props["data-wrap"] === "true"; + const wrap = codeButton(mounted, initialWrap ? "Disable line wrap" : "Wrap lines"); + const copy = codeButton(mounted, "Copy code"); + await act(async () => { + wrap.onClick?.({} as Parameters>[0]); + copy.onClick?.({} as Parameters>[0]); + }); + + const detailsButton = mounted.root.find( + (instance) => + instance.type === "button" && instance.props["data-markdown-details-summary"] === "", + ); + await act(async () => { + detailsButton.props.onClick({ nativeEvent: new Event("click") }); + }); + const details = mounted.root.findByProps({ "data-markdown-details": "" }); + expect(details.props["data-markdown-details-open"]).toBe("true"); + expect(writeText).toHaveBeenCalledWith("First code block\n"); + expect(highlight).toHaveBeenCalledTimes(1); + + for (let index = 0; index < 10; index += 1) { + await act(async () => { + mounted.update(); + }); + } + + expect(highlight).toHaveBeenCalledTimes(1); + expect(mounted.root.findByProps({ "data-language": "text" })).toBe(codeBlock); + expect(codeBlock.props["data-wrap"]).toBe(String(!initialWrap)); + expect(mounted.root.findByProps({ "data-markdown-details": "" })).toBe(details); + expect(details.props["data-markdown-details-open"]).toBe("true"); + await act(async () => { + mounted.update( + , + ); + }); + const copyUpdated = codeButton(mounted, "Copied"); + await act(async () => { + copyUpdated.onClick?.({} as Parameters>[0]); + }); + expect(writeText).toHaveBeenLastCalledWith("Updated code block\n"); + expect(highlight).toHaveBeenCalledTimes(2); + } finally { + await act(async () => renderer?.unmount()); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + } + }); + + it("edits the current task text and marker after reusing a renderer", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + let editedText: string | undefined; + const message = (text: string) => ( + { + editedText = setMarkdownTaskChecked(text, markerOffset, checked); + renderer!.update(message(editedText)); + }} + /> + ); + + try { + await act(async () => { + renderer = create(message("- [ ] First\n- [ ] Second")); + }); + const mounted = renderer!; + const originalInput = mounted.root.findAllByType("input")[1]!; + await act(async () => { + mounted.update(message("- [ ] A longer first task\n- [ ] Second")); + }); + + const input = mounted.root.findAllByType("input")[1]!; + const listItem = mounted.root.findAllByType("li")[1]!; + const { onChange } = input.props as ComponentProps<"input">; + if (!onChange) throw new Error("Task checkbox has no edit handler"); + await act(async () => { + onChange({ + currentTarget: { + checked: true, + closest: () => ({ + dataset: { taskMarkerOffset: String(listItem.props["data-task-marker-offset"]) }, + }), + }, + } as unknown as Parameters[0]); + }); + + expect(input).toBe(originalInput); + expect(editedText).toBe("- [ ] A longer first task\n- [x] Second"); + expect(mounted.root.findAllByType("input")[1]!.props.checked).toBe(true); + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); +}); + describe("canUseMarkdownFileShellActions", () => { const environmentId = EnvironmentId.make("environment-1"); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 75127ea124e8..2226605827dc 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1952,7 +1952,7 @@ function areMarkdownFileLinkPropsEqual( ); } -function ChatMarkdown({ +function useChatMarkdownState({ text, cwd, threadRef, @@ -1960,13 +1960,9 @@ function ChatMarkdown({ onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, - className, - lineBreaks = false, - parseRawHtml = true, onUseArtifactTemplate, imageBaseDir, onImageExpand, - extraRemarkPlugins = EMPTY_REMARK_PLUGINS, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); @@ -2270,11 +2266,8 @@ function ChatMarkdown({ }, [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); - /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component - * renderers that close over this message's metadata. useMemo keeps them stable until that - * metadata changes. */ - const markdownComponents = useMemo(() => { - const fileLinkChip = ( + const fileLinkChip = useCallback( + ( fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, className?: string, @@ -2339,433 +2332,507 @@ function ChatMarkdown({ className={className} /> ); - }; + }, + [ + canUseShellActions, + fileLinkParentSuffixByPath, + openFileInPanel, + openInPreferredEditor, + openMarkdownFileInPreview, + openMarkdownMedia, + preferredEditorMenuLabel, + resolvedTheme, + revealInFileManagerLabel, + revealMarkdownFileInFileManager, + threadRef, + ], + ); - return { - div({ node, children, ...props }) { - const artifactTemplate = artifactTemplateFromHastProperties(node?.properties); - if (artifactTemplate) { - return ( - - ); - } - return
{children}
; - }, - p({ node: _node, children, ...props }) { - return

{renderSkillInlineMarkdownChildren(children, skills)}

; - }, - blockquote({ node: _node, children, ...props }) { - const alert = - GITHUB_ALERT_PRESENTATIONS[ - String((props as Record)["data-alert"] ?? "") - ]; - if (!alert) { - return
{children}
; - } - // Not a
: the stylesheet mutes those, and an alert's body is ordinary - // text under a colored title โ€” which is how the host renders it. - return ( -
-

- - {alert.label} -

- {children} -
- ); - }, - ol({ node, start, style, ...props }) { - const itemCount = - node?.children?.filter((child) => child.type === "element" && child.tagName === "li") - .length ?? 0; - const gutterStyle = orderedListGutterStyle(itemCount, start); - return ( -
    - ); - }, - li({ node, children, ...props }) { - const listItemStart = node?.position?.start.offset; - const markerOffset = - typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; - return ( -
  1. - {renderSkillInlineMarkdownChildren(children, skills)} -
  2. - ); - }, - input({ node: _node, type, checked, disabled: _disabled, ...props }) { - if (type !== "checkbox" || !onTaskListChange) { - return ( - - ); - } - return ( - { - const markerOffset = Number( - event.currentTarget.closest("li")?.dataset.taskMarkerOffset, + const componentState = useMemo( + () => ({ + cwd, + diffThemeName, + expandMedia, + fileLinkChip, + imageBaseDir, + inlineCodeFileLinkMetaByText, + isStreaming, + linkTargetPreference, + markdownFileLinkMetaByHref, + onTaskListChange, + onUseArtifactTemplate, + openChangeRequestLink, + openExternalLinkInPreview, + openMarkdownMedia, + resolveThreadPullRequest, + resolvedTheme, + skills, + text, + threadRef, + updateThreadPullRequestLink, + }), + [ + cwd, + diffThemeName, + expandMedia, + fileLinkChip, + imageBaseDir, + inlineCodeFileLinkMetaByText, + isStreaming, + linkTargetPreference, + markdownFileLinkMetaByHref, + onTaskListChange, + onUseArtifactTemplate, + openChangeRequestLink, + openExternalLinkInPreview, + openMarkdownMedia, + resolveThreadPullRequest, + resolvedTheme, + skills, + text, + threadRef, + updateThreadPullRequestLink, + ], + ); + return { + componentState, + handleCopy, + markdownUrlTransform, + localMediaPreview, + setLocalMediaPreview, + }; +} + +const ChatMarkdownRendererContext = React.createContext< + ReturnType["componentState"] +>(null!); + +// Keep component types stable when streaming changes the message state. +const CHAT_MARKDOWN_COMPONENTS = { + div: function MarkdownDiv({ node, children, ...props }) { + const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext); + const artifactTemplate = artifactTemplateFromHastProperties(node?.properties); + if (artifactTemplate) { + return ( + + ); + } + return
    {children}
    ; + }, + p: function MarkdownParagraph({ node: _node, children, ...props }) { + const { skills } = use(ChatMarkdownRendererContext); + return

    {renderSkillInlineMarkdownChildren(children, skills)}

    ; + }, + blockquote: function MarkdownBlockquote({ node: _node, children, ...props }) { + const alert = + GITHUB_ALERT_PRESENTATIONS[String((props as Record)["data-alert"] ?? "")]; + if (!alert) { + return
    {children}
    ; + } + // Not a
    : the stylesheet mutes those, and an alert's body is ordinary + // text under a colored title โ€” which is how the host renders it. + return ( +
    +

    + + {alert.label} +

    + {children} +
    + ); + }, + ol: function MarkdownOrderedList({ node, start, style, ...props }) { + const itemCount = + node?.children?.filter((child) => child.type === "element" && child.tagName === "li") + .length ?? 0; + const gutterStyle = orderedListGutterStyle(itemCount, start); + return ( +
      + ); + }, + li: function MarkdownListItem({ node, children, ...props }) { + const { text, skills } = use(ChatMarkdownRendererContext); + const listItemStart = node?.position?.start.offset; + const markerOffset = + typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; + return ( +
    1. + {renderSkillInlineMarkdownChildren(children, skills)} +
    2. + ); + }, + input: function MarkdownInput({ node: _node, type, checked, disabled: _disabled, ...props }) { + const { onTaskListChange } = use(ChatMarkdownRendererContext); + if (type !== "checkbox" || !onTaskListChange) { + return ( + + ); + } + return ( + { + const markerOffset = Number(event.currentTarget.closest("li")?.dataset.taskMarkerOffset); + if (!Number.isSafeInteger(markerOffset)) return; + onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); + }} + /> + ); + }, + a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { + const { + cwd, + imageBaseDir, + markdownFileLinkMetaByHref, + threadRef, + openMarkdownMedia, + openChangeRequestLink, + linkTargetPreference, + openExternalLinkInPreview, + resolveThreadPullRequest, + updateThreadPullRequestLink, + fileLinkChip, + } = use(ChatMarkdownRendererContext); + const citation = href ? parseAssistantCitationHref(href) : null; + if (citation) return ; + const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; + const fileLinkMeta = normalizedHref + ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? + resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd)) + : null; + if (!fileLinkMeta) { + const faviconHost = resolveExternalWebLinkHost(href); + const pullRequestAutolink = String( + (props as Record)["data-pull-request-autolink"] ?? "", + ); + const pullRequestCopy = + pullRequestAutolink === "commit" + ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1] + : pullRequestAutolink === "reference" + ? plainHastText(node) + : undefined; + const isPullRequestAutolink = pullRequestCopy !== undefined; + const isSameDocumentLink = href?.startsWith("#") ?? false; + const onClick = props.onClick; + const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); + const linkChildren = {children}; + const link = ( + { + onClick?.(event); + if (isSameDocumentLink && href) { + handleMarkdownFragmentClick(event, href); + return; + } + if ( + href && + faviconHost !== null && + mediaKindFromPath(href) !== null && + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + event.stopPropagation(); + openMarkdownMedia(href); + return; + } + // A link to a change request in a workspace project opens beside the + // conversation instead of in a browser: it is the thing being talked about, and + // the panel it opens offers the browser as one of its actions. + if (!href || openChangeRequestLink(event, href)) return; + // Anything else follows the "Open links in" setting. The system browser + // keeps the `_blank` the shell already handles; the in-app browser needs + // the click intercepted here. A modifier click is the way out of the + // in-app default, so it is left to the shell too. + if ( + event.defaultPrevented || + resolveLinkTarget({ + url: href, + event, + preference: linkTargetPreference, + canOpenInApp: canOpenInPreview, + }) !== "app" + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + // The click was taken from the shell, so an in-app open that fails + // hands the link to the system browser instead of dropping it. + void openExternalLinkInPreview(href).then((result) => { + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target: href }, + result.cause, ); - if (!Number.isSafeInteger(markerOffset)) return; - onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); - }} - /> - ); - }, - a({ node, href, children, title: _title, ...props }) { - const citation = href ? parseAssistantCitationHref(href) : null; - if (citation) return ; - const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; - const fileLinkMeta = normalizedHref - ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? - resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd)) - : null; - if (!fileLinkMeta) { - const faviconHost = resolveExternalWebLinkHost(href); - const pullRequestAutolink = String( - (props as Record)["data-pull-request-autolink"] ?? "", - ); - const pullRequestCopy = - pullRequestAutolink === "commit" - ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1] - : pullRequestAutolink === "reference" - ? plainHastText(node) - : undefined; - const isPullRequestAutolink = pullRequestCopy !== undefined; - const isSameDocumentLink = href?.startsWith("#") ?? false; - const onClick = props.onClick; - const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); - const linkChildren = {children}; - const link = ( - { - onClick?.(event); - if (isSameDocumentLink && href) { - handleMarkdownFragmentClick(event, href); - return; - } - if ( - href && - faviconHost !== null && - mediaKindFromPath(href) !== null && - !event.defaultPrevented && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - !event.altKey - ) { - event.preventDefault(); - event.stopPropagation(); - openMarkdownMedia(href); - return; + void readLocalApi()?.shell.openExternal(href); + }); + }} + onContextMenu={(event) => { + if (!href || !faviconHost) return; + event.preventDefault(); + event.stopPropagation(); + const api = readLocalApi(); + if (!api) return; + const pullRequest = resolveThreadPullRequest(href); + const currentPullRequest = + threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; + const threadLinkAction = + currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href) + ? "unlink-from-thread" + : pullRequest === null + ? undefined + : "link-to-thread"; + void showExternalLinkContextMenu({ + href, + canOpenInPreview, + threadLinkAction, + position: { x: event.clientX, y: event.clientY }, + showContextMenu: (items, position) => api.contextMenu.show(items, position), + openInPreview: async (target) => { + const result = await openExternalLinkInPreview(target); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); } - // A link to a change request in a workspace project opens beside the - // conversation instead of in a browser: it is the thing being talked about, and - // the panel it opens offers the browser as one of its actions. - if (!href || openChangeRequestLink(event, href)) return; - // Anything else follows the "Open links in" setting. The system browser - // keeps the `_blank` the shell already handles; the in-app browser needs - // the click intercepted here. A modifier click is the way out of the - // in-app default, so it is left to the shell too. + }, + openExternal: (target) => api.shell.openExternal(target), + copyLink: (target) => writeTextToClipboard(target, "link"), + updateThreadLink: updateThreadPullRequestLink, + reportFailure: (operation, cause) => { + reportMarkdownActionFailure({ operation, target: href }, cause); if ( - event.defaultPrevented || - resolveLinkTarget({ - url: href, - event, - preference: linkTargetPreference, - canOpenInApp: canOpenInPreview, - }) !== "app" + operation === "link-pull-request-to-thread" || + operation === "unlink-pull-request-from-thread" ) { - return; - } - event.preventDefault(); - event.stopPropagation(); - // The click was taken from the shell, so an in-app open that fails - // hands the link to the system browser instead of dropping it. - void openExternalLinkInPreview(href).then((result) => { - if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; - reportMarkdownActionFailure( - { operation: "open-link-in-preview", target: href }, - result.cause, + toastManager.add( + stackedThreadToast({ + type: "error", + title: + operation === "link-pull-request-to-thread" + ? "Unable to link pull request" + : "Unable to unlink pull request", + description: cause instanceof Error ? cause.message : "The request failed.", + }), ); - void readLocalApi()?.shell.openExternal(href); - }); - }} - onContextMenu={(event) => { - if (!href || !faviconHost) return; - event.preventDefault(); - event.stopPropagation(); - const api = readLocalApi(); - if (!api) return; - const pullRequest = resolveThreadPullRequest(href); - const currentPullRequest = - threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; - const threadLinkAction = - currentPullRequest != null && - matchesLinkedPullRequestUrl(currentPullRequest, href) - ? "unlink-from-thread" - : pullRequest === null - ? undefined - : "link-to-thread"; - void showExternalLinkContextMenu({ - href, - canOpenInPreview, - threadLinkAction, - position: { x: event.clientX, y: event.clientY }, - showContextMenu: (items, position) => api.contextMenu.show(items, position), - openInPreview: async (target) => { - const result = await openExternalLinkInPreview(target); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure( - { operation: "open-link-in-preview", target }, - result.cause, - ); - } - }, - openExternal: (target) => api.shell.openExternal(target), - copyLink: (target) => writeTextToClipboard(target, "link"), - updateThreadLink: updateThreadPullRequestLink, - reportFailure: (operation, cause) => { - reportMarkdownActionFailure({ operation, target: href }, cause); - if ( - operation === "link-pull-request-to-thread" || - operation === "unlink-pull-request-from-thread" - ) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: - operation === "link-pull-request-to-thread" - ? "Unable to link pull request" - : "Unable to unlink pull request", - description: - cause instanceof Error ? cause.message : "The request failed.", - }), - ); - } - }, - }); - }} - > - {faviconHost && hastHasText(node) && !isPullRequestAutolink ? ( - - {linkChildren} - - ) : ( - linkChildren - )} - - ); - if (!faviconHost || !href) { - return link; - } - return ( - - - - {href} - - - ); - } + } + }, + }); + }} + > + {faviconHost && hastHasText(node) && !isPullRequestAutolink ? ( + + {linkChildren} + + ) : ( + linkChildren + )} + + ); + if (!faviconHost || !href) { + return link; + } + return ( + + + + {href} + + + ); + } + return fileLinkChip( + fileLinkMeta, + `[${fileLinkMeta.basename}](${normalizedHref})`, + props.className, + normalizedHref, + ); + }, + code: function MarkdownCode({ node, children, className, ...props }) { + const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip } = use( + ChatMarkdownRendererContext, + ); + if (node?.properties?.dataInlineCode != null) { + const codeText = nodeToPlainText(children); + const fileLinkMeta = + inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? + resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd); + if (fileLinkMeta) { return fileLinkChip( fileLinkMeta, - `[${fileLinkMeta.basename}](${normalizedHref})`, - props.className, - normalizedHref, + `\`${codeText}\``, + undefined, + inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), ); - }, - code({ node, children, className, ...props }) { - if (node?.properties?.dataInlineCode != null) { - const codeText = nodeToPlainText(children); - const fileLinkMeta = - inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? - resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd); - if (fileLinkMeta) { - return fileLinkChip( - fileLinkMeta, - `\`${codeText}\``, - undefined, - inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), - ); - } - } + } + } + return ( + + {children} + + ); + }, + img: function MarkdownImage({ node, title, src, alt, ...props }) { + const { expandMedia, cwd, imageBaseDir, threadRef } = use(ChatMarkdownRendererContext); + const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; + const localSrc = node?.properties?.dataLocalSrc; + const markdownTitle = node?.properties?.dataMarkdownTitle; + const authoredSrc = typeof localSrc === "string" ? localSrc : src; + const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; + const srcString = + typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : ""; + const classifiedSrc = + typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString; + const altText = alt ?? ""; + const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); + const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); + const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); + const kind = mediaKindFromPath(classifiedSrc) ?? "image"; + if (imageSource._tag === "Direct") { + const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); + const originalUrl = + resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined; + const reference = mediaUrlReference(imageSource.uri); + const actionsSource: MediaActionSource = { + kind, + name: altText || kind, + src: mediaSrc, + ...(reference ? { reference } : {}), + }; + if (kind === "video") { return ( - - {children} - + ); - }, - img: function MarkdownImage({ node, title, src, alt, ...props }) { - const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; - const localSrc = node?.properties?.dataLocalSrc; - const markdownTitle = node?.properties?.dataMarkdownTitle; - const authoredSrc = typeof localSrc === "string" ? localSrc : src; - const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; - const srcString = - typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : ""; - const classifiedSrc = - typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString; - const altText = alt ?? ""; - const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); - const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); - const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); - const kind = mediaKindFromPath(classifiedSrc) ?? "image"; - if (imageSource._tag === "Direct") { - const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); - const originalUrl = - resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined; - const reference = mediaUrlReference(imageSource.uri); - const actionsSource: MediaActionSource = { - kind, - name: altText || kind, - src: mediaSrc, - ...(reference ? { reference } : {}), - }; - if (kind === "video") { - return ( - - ); - } - return ( - - {altText} - - ); - } - if (imageSource._tag === "WorkspaceFile" && threadRef) { - return ( - - ); - } - return ; - }, - table({ node: _node, ...props }) { - return ; - }, - details({ node: _node, children, open: detailsOpen }) { - return {children}; - }, - pre({ node, children, ...props }) { - const codeBlock = extractCodeBlock(children); - if (!codeBlock) { - return
      {children}
      ; - } + } + return ( + + {altText} + + ); + } + if (imageSource._tag === "WorkspaceFile" && threadRef) { + return ( + + ); + } + return ; + }, + table: function MarkdownTableRenderer({ node: _node, ...props }) { + return ; + }, + details: function MarkdownDetailsRenderer({ node: _node, children, open: detailsOpen }) { + return {children}; + }, + pre: function MarkdownPre({ node, children, ...props }) { + const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext); + const codeBlock = extractCodeBlock(children); + if (!codeBlock) { + return
      {children}
      ; + } - const language = extractFenceLanguage(codeBlock.className); - const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); - return ( - - {children}}> - {children}}> - - - - - ); - }, - }; - }, [ - canUseShellActions, - cwd, - diffThemeName, - fileLinkParentSuffixByPath, - inlineCodeFileLinkMetaByText, - imageBaseDir, - isStreaming, - linkTargetPreference, - markdownFileLinkMetaByHref, - onTaskListChange, - onUseArtifactTemplate, - onImageExpand, - expandMedia, - openMarkdownMedia, - openFileInPanel, - openInPreferredEditor, - openChangeRequestLink, - openExternalLinkInPreview, - openMarkdownFileInPreview, - preferredEditorMenuLabel, - resolveThreadPullRequest, - resolvedTheme, - revealMarkdownFileInFileManager, - revealInFileManagerLabel, - skills, - text, - threadRef, - updateThreadPullRequestLink, - ]); - /* eslint-enable react/no-unstable-nested-components */ + const language = extractFenceLanguage(codeBlock.className); + const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); + return ( + + {children}}> + {children}}> + + + + + ); + }, +} satisfies Components; +function ChatMarkdown({ + text, + className, + lineBreaks = false, + parseRawHtml = true, + extraRemarkPlugins = EMPTY_REMARK_PLUGINS, + ...props +}: ChatMarkdownProps) { + const { + componentState, + handleCopy, + markdownUrlTransform, + localMediaPreview, + setLocalMediaPreview, + } = useChatMarkdownState({ text, ...props }); const remarkPlugins = useMemo( () => [ ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), @@ -2785,15 +2852,17 @@ function ChatMarkdown({ )} onCopy={handleCopy} > - - {text} - + + + {text} + + {localMediaPreview ? ( =0.10.0'} @@ -19795,6 +19806,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + react-test-renderer@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + react-is: 19.2.7 + scheduler: 0.27.0 + react@19.2.3: {} react@19.2.6: {} From 7cf5b284e6e37895f535433c77f728b3e4292c9b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 07:03:49 -0700 Subject: [PATCH 014/262] perf(mobile): skip unused legacy list work (#9679) --- apps/mobile/src/features/home/HomeScreen.tsx | 92 ++++++++++--------- .../mobile/src/features/home/homeListItems.ts | 2 + .../threads/ThreadNavigationSidebar.tsx | 84 +++++++++-------- 3 files changed, 101 insertions(+), 77 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index c06cbcf1e913..4c41ce2bf150 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -61,6 +61,7 @@ import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, DEFAULT_GROUP_DISPLAY_STATE, + EMPTY_HOME_LIST_LAYOUT, homeListItemsAreEqual, nextGroupDisplayState, type HomeGroupDisplayAction, @@ -339,48 +340,57 @@ export function HomeScreen(props: HomeScreenProps) { ); const scopedProjects = useMemo( () => - selectedProjectRefKeys === null - ? props.projects - : props.projects.filter((project) => - selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)), - ), - [props.projects, selectedProjectRefKeys], + threadListV2Enabled + ? [] + : selectedProjectRefKeys === null + ? props.projects + : props.projects.filter((project) => + selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)), + ), + [threadListV2Enabled, props.projects, selectedProjectRefKeys], ); const scopedThreads = useMemo( () => - selectedProjectRefKeys === null - ? props.threads - : props.threads.filter((thread) => - selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [props.threads, selectedProjectRefKeys], + threadListV2Enabled + ? [] + : selectedProjectRefKeys === null + ? props.threads + : props.threads.filter((thread) => + selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), + ), + [threadListV2Enabled, props.threads, selectedProjectRefKeys], ); const scopedPendingTasks = useMemo( () => - selectedProjectRefKeys === null - ? props.pendingTasks - : props.pendingTasks.filter((pendingTask) => - selectedProjectRefKeys.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + threadListV2Enabled + ? [] + : selectedProjectRefKeys === null + ? props.pendingTasks + : props.pendingTasks.filter((pendingTask) => + selectedProjectRefKeys.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + ), ), - ), - [props.pendingTasks, selectedProjectRefKeys], + [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys], ); const projectGroups = useMemo( () => - buildHomeThreadGroups({ - projects: scopedProjects, - threads: scopedThreads, - pendingTasks: scopedPendingTasks, - environmentId: props.selectedEnvironmentId, - searchQuery: props.searchQuery, - matchedThreadKeys, - projectSortOrder: props.projectSortOrder, - threadSortOrder: props.threadSortOrder, - projectGroupingMode: props.projectGroupingMode, - }), + threadListV2Enabled + ? [] + : buildHomeThreadGroups({ + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, + environmentId: props.selectedEnvironmentId, + searchQuery: props.searchQuery, + matchedThreadKeys, + projectSortOrder: props.projectSortOrder, + threadSortOrder: props.threadSortOrder, + projectGroupingMode: props.projectGroupingMode, + }), [ + threadListV2Enabled, props.projectGroupingMode, props.projectSortOrder, props.searchQuery, @@ -396,12 +406,14 @@ export function HomeScreen(props: HomeScreenProps) { const hasSearchQuery = props.searchQuery.trim().length > 0; const listLayout = useMemo( () => - buildHomeListLayout({ - groups: projectGroups, - displayStates: effectiveGroupDisplayStates, - showAllThreads: hasSearchQuery, - }), - [projectGroups, effectiveGroupDisplayStates, hasSearchQuery], + threadListV2Enabled + ? EMPTY_HOME_LIST_LAYOUT + : buildHomeListLayout({ + groups: projectGroups, + displayStates: effectiveGroupDisplayStates, + showAllThreads: hasSearchQuery, + }), + [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery], ); const projectCwdByKey = useMemo(() => { @@ -1033,7 +1045,7 @@ export function HomeScreen(props: HomeScreenProps) { // so the v1 check already covers v2. const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; - const hasResults = projectGroups.length > 0; + const hasResults = threadListV2Enabled ? threadListV2Items.length > 0 : projectGroups.length > 0; const selectedEnvironmentLabel = props.selectedEnvironmentId === null ? null @@ -1097,10 +1109,8 @@ export function HomeScreen(props: HomeScreenProps) { ) ) : null; - // Self-contained: v1's listEmpty keys off projectGroups, which ignores the - // v2 project scope, so it can be null (results elsewhere) while this list - // is empty. Snoozed threads need no special empty state: their shelf header - // is a list row even while collapsed. + // Use the v2 project scope for its empty state. Snoozed threads need no + // special empty state: their shelf header is a list row even while collapsed. const v2ListEmpty = hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts index eb3f2a5de199..6709a81e9d1e 100644 --- a/apps/mobile/src/features/home/homeListItems.ts +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -62,6 +62,8 @@ export interface HomeListLayout { readonly stickyHeaderIndices: ReadonlyArray; } +export const EMPTY_HOME_LIST_LAYOUT: HomeListLayout = { items: [], stickyHeaderIndices: [] }; + export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less"; export function nextGroupDisplayState( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d03a4ee05dd1..07357a1b7524 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -45,6 +45,7 @@ import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; import { buildHomeListLayout, DEFAULT_GROUP_DISPLAY_STATE, + EMPTY_HOME_LIST_LAYOUT, homeListItemsAreEqual, nextGroupDisplayState, type HomeGroupDisplayAction, @@ -269,47 +270,56 @@ function ThreadNavigationSidebarPane( ); const scopedProjects = useMemo( () => - selectedProjectRefs === null - ? projects - : projects.filter((project) => - selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), - ), - [projects, selectedProjectRefs], + threadListV2Enabled + ? [] + : selectedProjectRefs === null + ? projects + : projects.filter((project) => + selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), + ), + [threadListV2Enabled, projects, selectedProjectRefs], ); const scopedThreads = useMemo( () => - selectedProjectRefs === null - ? threads - : threads.filter((thread) => - selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [selectedProjectRefs, threads], + threadListV2Enabled + ? [] + : selectedProjectRefs === null + ? threads + : threads.filter((thread) => + selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), + ), + [threadListV2Enabled, selectedProjectRefs, threads], ); const scopedPendingTasks = useMemo( () => - selectedProjectRefs === null - ? pendingTasks - : pendingTasks.filter((pendingTask) => - selectedProjectRefs.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + threadListV2Enabled + ? [] + : selectedProjectRefs === null + ? pendingTasks + : pendingTasks.filter((pendingTask) => + selectedProjectRefs.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + ), ), - ), - [pendingTasks, selectedProjectRefs], + [threadListV2Enabled, pendingTasks, selectedProjectRefs], ); const groups = useMemo( () => - buildHomeThreadGroups({ - projects: scopedProjects, - threads: scopedThreads, - pendingTasks: scopedPendingTasks, - environmentId: options.selectedEnvironmentId, - searchQuery: props.searchQuery, - matchedThreadKeys, - projectSortOrder: options.projectSortOrder, - threadSortOrder: options.threadSortOrder, - projectGroupingMode: options.projectGroupingMode, - }), + threadListV2Enabled + ? [] + : buildHomeThreadGroups({ + projects: scopedProjects, + threads: scopedThreads, + pendingTasks: scopedPendingTasks, + environmentId: options.selectedEnvironmentId, + searchQuery: props.searchQuery, + matchedThreadKeys, + projectSortOrder: options.projectSortOrder, + threadSortOrder: options.threadSortOrder, + projectGroupingMode: options.projectGroupingMode, + }), [ + threadListV2Enabled, matchedThreadKeys, options, props.searchQuery, @@ -334,12 +344,14 @@ function ThreadNavigationSidebarPane( const hasSearchQuery = props.searchQuery.trim().length > 0; const listLayout = useMemo( () => - buildHomeListLayout({ - groups, - displayStates: groupDisplayStates, - showAllThreads: hasSearchQuery, - }), - [groups, groupDisplayStates, hasSearchQuery], + threadListV2Enabled + ? EMPTY_HOME_LIST_LAYOUT + : buildHomeListLayout({ + groups, + displayStates: groupDisplayStates, + showAllThreads: hasSearchQuery, + }), + [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery], ); const projectCwdByKey = useMemo(() => { const map = new Map(); From 8e3aa324b57dd645980b203d5f8a5b9f9dc53a84 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 07:13:22 -0700 Subject: [PATCH 015/262] perf(marketing): serve images at their display size (#9682) --- apps/marketing/package.json | 3 +- .../{public => src/assets}/app-desktop.webp | Bin .../{public => src/assets}/icon.webp | Bin apps/marketing/src/layouts/Layout.astro | 14 +++++++- apps/marketing/src/pages/index.astro | 34 +++++++++++++----- pnpm-lock.yaml | 7 ++-- 6 files changed, 45 insertions(+), 13 deletions(-) rename apps/marketing/{public => src/assets}/app-desktop.webp (100%) rename apps/marketing/{public => src/assets}/icon.webp (100%) diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 912faf88164d..78c121d14fe2 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@t3tools/shared": "workspace:*", - "astro": "^7.0.3" + "astro": "^7.0.3", + "sharp": "0.34.5" }, "devDependencies": { "@astrojs/check": "^0.9.7", diff --git a/apps/marketing/public/app-desktop.webp b/apps/marketing/src/assets/app-desktop.webp similarity index 100% rename from apps/marketing/public/app-desktop.webp rename to apps/marketing/src/assets/app-desktop.webp diff --git a/apps/marketing/public/icon.webp b/apps/marketing/src/assets/icon.webp similarity index 100% rename from apps/marketing/public/icon.webp rename to apps/marketing/src/assets/icon.webp diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 686b555fd4c0..0b022821a454 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -1,4 +1,6 @@ --- +import { Image } from "astro:assets"; +import appIcon from "../assets/icon.webp"; import { ANDROID_PLAY_STORE_URL, GITHUB_REPOSITORY_URL, @@ -67,7 +69,17 @@ const {