From 83a82a46b0631234ac85748b912566d6f4b1dee3 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Wed, 7 Oct 2026 18:36:19 -0700 Subject: [PATCH 001/273] chore(review): remove the custom Approvability check (#17018) --- .coderabbit.config.ts | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 5a9505c70276..2394e757b72d 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -1,21 +1,7 @@ import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config"; -const approvability = `Fail when a maintainer should read this pull request before CodeRabbit approves it, and name the rule and file. Fail if it: - -- Changes a product default: a setting's default value, or what users get without opting in. Making a feature do what it already promises is a bug fix, not a default change. -- Adds or broadens a directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic, including file-level, line-level, and configuration-level overrides. -- Adds a subsystem or user workflow, or is a large refactor across apps or packages. -- Changes packages/contracts or persisted data in a way that existing clients or stored data might not accept. -- Changes authentication, pairing, credentials, secrets, or remote connection trust. -- Adds or changes an external side effect, such as acting on GitHub, publishing a release, or calling a webhook. -- Adds, upgrades, or patches a dependency. -- Changes CI or release configuration, agent or contributor instructions, or any review tool's configuration, including .github/, AGENTS.md, CONTRIBUTING.md, .agents/, and .coderabbit.config.ts. - -Otherwise pass. A focused bug fix, copy or layout fix, revert, or docs-only or test-only change passes unless a rule above applies. If you cannot decide, fail rather than report inconclusive. When failing, say that the pull request needs a maintainer's review. -`; - // Org members and collaborators merge their own pull requests. On anyone else's, CodeRabbit -// requests changes until its comments are resolved and its checks pass, then approves. +// requests changes until its comments are resolved, then approves. const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? ""); @@ -31,13 +17,6 @@ export default defineConfig((ctx) => ({ pre_merge_checks: { docstrings: { mode: "off" }, override_requested_reviewers_only: isGated(ctx), - custom_checks: [ - { - name: "Approvability", - mode: isGated(ctx) ? "error" : "off", - instructions: approvability, - }, - ], }, path_filters: [ // Vendored read-only reference checkouts of upstream Effect and Alchemy From f9e16b3f66476ad1e213152708c9aa8b035085a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 19:21:59 -0700 Subject: [PATCH 002/273] perf(mobile): show the cached thread list sooner and stop the freeze after it (#16713) Co-authored-by: Claude Opus 5.5 (1M context) --- .../environment-cache-store.test.ts | 75 ++++ .../src/connection/environment-cache-store.ts | 15 +- .../src/connection/shell-cache-decoding.ts | 19 + .../src/platform/persistence.ts | 3 +- .../src/state/environmentHttpAuth.test.ts | 20 +- .../src/state/shell-sync.test.ts | 346 +++++++++++++++++- packages/client-runtime/src/state/shell.ts | 131 ++++++- .../src/state/shellPullRequests.ts | 97 +++++ .../src/state/shellReducer.test.ts | 44 ++- .../client-runtime/src/state/shellReducer.ts | 36 ++ .../src/state/shellSnapshotHttp.ts | 51 ++- 11 files changed, 816 insertions(+), 21 deletions(-) create mode 100644 apps/mobile/src/connection/shell-cache-decoding.ts create mode 100644 packages/client-runtime/src/state/shellPullRequests.ts diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 8e4ce3e62114..e1d627b50466 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -10,12 +10,14 @@ import { ThreadId, type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadDetailSnapshot, + type ThreadPullRequestLink, type VcsListRefsResult, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import { vi } from "vite-plus/test"; import * as Deferred from "effect/Deferred"; @@ -254,6 +256,79 @@ describe("mobile SQLite environment cache store", () => { }), ); + it.live("returns cached threads before their pull request links, which load after", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe( + Effect.provideService(MobileDatabase.MobileDatabase, memory.database), + ); + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "agent", + linkedAt: "2026-07-29T12:00:00.000Z", + snapshot: null, + stack: null, + }; + const linked = { ...SHELL_SNAPSHOT.threads[0]!, pullRequests: [link] }; + const unlinked = { ...SHELL_SNAPSHOT.threads[0]!, id: ThreadId.make("thread-2") }; + yield* store.saveShell(ENVIRONMENT_ID, { + ...SHELL_SNAPSHOT, + threads: [linked, unlinked], + }); + + const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); + expect(shell.threads.map((thread) => thread.pullRequests)).toEqual([undefined, undefined]); + const links = yield* shell.loadPullRequests!; + expect([...links]).toEqual([[THREAD_ID, [link]]]); + }), + ); + + it.live("keeps cached threads when their pull request links cannot be read", () => { + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + return Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe( + Effect.provideService(MobileDatabase.MobileDatabase, memory.database), + ); + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 5, + url: "https://github.com/pingdotgg/t3code/pull/5", + source: "agent", + linkedAt: "2026-07-29T12:00:00.000Z", + snapshot: null, + stack: null, + }; + const readable = { + ...SHELL_SNAPSHOT.threads[0]!, + id: ThreadId.make("thread-2"), + pullRequests: [link], + }; + yield* store.saveShell(ENVIRONMENT_ID, { + ...SHELL_SNAPSHOT, + threads: [SHELL_SNAPSHOT.threads[0]!, readable], + }); + const id = cacheId(ENVIRONMENT_ID, "shell", "snapshot"); + const payload = JSON.parse(memory.values.get(id)!); + payload.snapshot.threads[0].pullRequests = [{ number: -1 }]; + memory.values.set(id, JSON.stringify(payload)); + + const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); + expect(shell.threads.map((thread) => thread.id)).toEqual([THREAD_ID, readable.id]); + // Only the thread with the unreadable link loses its links. + expect([...(yield* shell.loadPullRequests!)]).toEqual([[readable.id, [link]]]); + expect(messages).toHaveLength(1); + expect(memory.removed).toEqual([]); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + it.effect("round-trips schema-validated VCS refs", () => Effect.gen(function* () { const memory = makeDatabase(); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 70c2a91f67af..99019d1d6921 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -1,6 +1,5 @@ import { ORCHESTRATION_CACHE_SCHEMA_VERSION, - StoredOrchestrationShellSnapshot, StoredOrchestrationThreadSnapshot, Persistence, } from "@t3tools/client-runtime/platform"; @@ -16,6 +15,7 @@ import { attachProjectFaviconDatabase, projectFaviconDatabaseCache, } from "../lib/projectFaviconDatabaseCache"; +import { decodeStoredShellSnapshot } from "./shell-cache-decoding"; const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; const VCS_REFS_CACHE_SCHEMA_VERSION = 1; @@ -32,9 +32,6 @@ const StoredVcsRefs = Schema.Struct({ refs: VcsListRefsResult, }); -const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( - Schema.fromJsonString(StoredOrchestrationShellSnapshot), -); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredOrchestrationThreadSnapshot), ); @@ -113,7 +110,15 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), + }).pipe( + // Read cached project icons in parallel with the shell so the first rows can show + // them, instead of making the thread list wait for the icon read after the decode. + Effect.zipWith( + Effect.promise(() => projectFaviconDatabaseCache.hydrate()), + (snapshot) => snapshot, + { concurrent: true }, + ), + ), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ diff --git a/apps/mobile/src/connection/shell-cache-decoding.ts b/apps/mobile/src/connection/shell-cache-decoding.ts new file mode 100644 index 000000000000..ef70036e5241 --- /dev/null +++ b/apps/mobile/src/connection/shell-cache-decoding.ts @@ -0,0 +1,19 @@ +import { StoredOrchestrationShellSnapshot } from "@t3tools/client-runtime/platform"; +import { deferPullRequests, detachPullRequests } from "@t3tools/client-runtime/state/shell"; +import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeStoredShell = Schema.decodeUnknownEffect(StoredOrchestrationShellSnapshot); + +/** + * Decode the shell cache payload with each thread's pull request links left out, so the + * thread list can paint first; `loadPullRequests` decodes the links afterwards. + */ +export const decodeStoredShellSnapshot = Effect.fnUntraced(function* (raw: string) { + const parsed = yield* decodeJson(raw); + const rawLinks = Predicate.isObject(parsed) ? detachPullRequests(parsed.snapshot) : []; + const stored = yield* decodeStoredShell(parsed); + return { ...stored, snapshot: deferPullRequests(stored.snapshot, rawLinks) }; +}); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index c146adee1e88..29463ce6da58 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type { ConnectionRegistration } from "../connection/catalog.ts"; +import type { DeferredShellSnapshot } from "../state/shellPullRequests.ts"; import type { ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; export class ConnectionPersistenceError extends Schema.TaggedError()( @@ -82,7 +83,7 @@ export class EnvironmentCacheStore extends Context.Service< { readonly loadShell: ( environmentId: EnvironmentId, - ) => Effect.Effect, ConnectionPersistenceError>; + ) => Effect.Effect, ConnectionPersistenceError>; readonly saveShell: ( environmentId: EnvironmentId, snapshot: OrchestrationV2ShellSnapshot, diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index c7c7fc53197a..61504668427e 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -204,7 +204,11 @@ const LOADERS: ReadonlyArray<{ path: "/api/orchestration/shell", response: SHELL, expected: SHELL, - load: fetchEnvironmentShellSnapshot, + // Pull request links decode separately; the rows match the response on their own. + load: (input: HttpInput) => + fetchEnvironmentShellSnapshot(input).pipe( + Effect.map(({ loadPullRequests: _links, ...snapshot }) => snapshot), + ), }, { name: "thread snapshot", @@ -254,6 +258,20 @@ describe("authenticated environment HTTP requests", () => { }), ); + it.effect("keeps the status of a shell snapshot error that is not a declared error", () => + Effect.gen(function* () { + const harness = makeHarness(() => Response.json({ error: "bad_gateway" }, { status: 502 })); + const error = yield* fetchEnvironmentShellSnapshot(harness.input).pipe( + Effect.provide(harness.httpLayer), + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "RemoteEnvironmentAuthUndeclaredStatusError", + status: 502, + }); + }), + ); + it.effect.each(LOADERS)("uses current relay authorization and endpoint for $name", (loader) => Effect.gen(function* () { const harness = makeHarness(() => Response.json(loader.response)); diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 69d728316498..b20d3e9cda19 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -3,6 +3,8 @@ import { ORCHESTRATION_V2_WS_METHODS, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, + ThreadId, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -29,7 +31,7 @@ import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { makeEnvironmentShellState } from "./shell.ts"; import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; -import { v2Project, v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts"; +import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -953,4 +955,346 @@ describe("environment shell synchronization", () => { expect(Option.getOrThrow(state.snapshot).projects[0]?.title).toBe("Server reset"); }), ); + + it.effect("fills deferred cached pull request links without overwriting newer rows", () => + Effect.gen(function* () { + const link = (number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }); + const otherThreadId = ThreadId.make("thread-other"); + const cachedSnapshot: OrchestrationV2ShellSnapshot = { + ...v2ShellSnapshot, + snapshotSequence: 5, + threads: [v2ThreadShell, { ...v2ThreadShell, id: otherThreadId }], + }; + const linksReady = yield* Deferred.make(); + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => + Effect.succeedSome({ + ...cachedSnapshot, + loadPullRequests: Deferred.await(linksReady).pipe( + Effect.as( + new Map([ + [v2ThreadShell.id, [link(1)]], + [otherThreadId, [link(2)]], + ]), + ), + ), + }), + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + // No HTTP snapshot, so the cached rows stay until the socket sends deltas. + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => Effect.succeedNone, + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + + const threadsOf = (state: { + readonly snapshot: Option.Option; + }) => Option.getOrThrow(state.snapshot).threads; + expect(threadsOf(yield* SubscriptionRef.get(shellState))).toEqual(cachedSnapshot.threads); + + // The server updates one row before the cached links finish decoding. + const serverRow = { ...v2ThreadShell, title: "From server", pullRequests: [link(9)] }; + yield* Queue.offer(events, { + kind: "thread.updated", + sequence: 6, + location: "active", + thread: serverRow, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => threadsOf(state)[0]?.title === "From server"), + Stream.runHead, + ); + + yield* Deferred.succeed(linksReady, undefined); + const filled = yield* SubscriptionRef.changes(shellState).pipe( + Stream.map(threadsOf), + Stream.filter((threads) => threads[1]?.pullRequests !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(filled[0]).toBe(serverRow); + expect(filled[1]?.pullRequests).toEqual([link(2)]); + }), + ); + + it.effect("shows HTTP rows before their pull request links and saves them once filled", () => + Effect.gen(function* () { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const httpSnapshot: OrchestrationV2ShellSnapshot = { + ...v2ShellSnapshot, + snapshotSequence: 3, + }; + const linksReady = yield* Deferred.make(); + const saved = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: (_environmentId, snapshot) => Queue.offer(saved, snapshot), + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => + Effect.succeedSome({ + ...httpSnapshot, + loadPullRequests: Deferred.await(linksReady).pipe( + Effect.as(new Map([[v2ThreadShell.id, [link]]])), + ), + }), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + + const rows = yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => Option.isSome(state.snapshot)), + Stream.runHead, + Effect.map((state) => Option.getOrThrow(Option.getOrThrow(state).snapshot)), + ); + expect(rows.threads[0]?.pullRequests).toBeUndefined(); + expect(rows).not.toHaveProperty("loadPullRequests"); + + yield* Deferred.succeed(linksReady, undefined); + const filled = yield* SubscriptionRef.changes(shellState).pipe( + Stream.map((state) => Option.getOrThrow(state.snapshot).threads[0]), + Stream.filter((thread) => thread?.pullRequests !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(filled?.pullRequests).toEqual([link]); + + // The cache must end up with the links: the last save carries them. + yield* TestClock.adjust("30 seconds"); + const saves = yield* Queue.takeAll(saved); + expect(saves.at(-1)?.threads[0]?.pullRequests).toEqual([link]); + }), + ); + + it.effect("does not save rows whose deferred links are still pending", () => + Effect.gen(function* () { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + url: "https://github.com/pingdotgg/t3code/pull/8", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const saved = yield* Ref.make>([]); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: (_environmentId, snapshot) => Ref.update(saved, (all) => [...all, snapshot]), + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => + Effect.succeedSome({ + ...v2ShellSnapshot, + snapshotSequence: 3, + // Links never finish decoding before the shell closes. + loadPullRequests: Effect.never.pipe(Effect.as(new Map([[v2ThreadShell.id, [link]]]))), + }), + }); + const scope = yield* Scope.make(); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + Scope.provide(scope), + ); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => Option.isSome(state.snapshot)), + Stream.runHead, + ); + + // Neither the throttled worker nor the closing flush may save the rows without links. + yield* TestClock.adjust("30 seconds"); + yield* Scope.close(scope, Exit.void); + expect(yield* Ref.get(saved)).toEqual([]); + }), + ); + + it.effect("drops an older snapshot's links once a newer snapshot reuses its rows", () => + Effect.gen(function* () { + const link = (number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }); + const firstLinks = yield* Deferred.make(); + const secondLinks = yield* Deferred.make(); + // Set once each snapshot's links start decoding, i.e. once its rows are applied. + const firstFillStarted = yield* Deferred.make(); + const loads = yield* Ref.make(0); + const subscriptions = yield* Ref.make(0); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => + Stream.unwrap(Ref.update(subscriptions, (n) => n + 1).pipe(Effect.as(Stream.never))), + } as unknown as WsRpcProtocolClient; + const activeSession = yield* SubscriptionRef.make>( + Option.some(session(client)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + // Both snapshots carry the same row; only its pull request links differ. + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => + Ref.updateAndGet(loads, (n) => n + 1).pipe( + Effect.map((n) => + Option.some({ + ...v2ShellSnapshot, + snapshotSequence: n, + loadPullRequests: (n === 1 + ? Deferred.succeed(firstFillStarted, undefined).pipe( + Effect.andThen(Deferred.await(firstLinks)), + ) + : Deferred.await(secondLinks) + ).pipe(Effect.as(new Map([[v2ThreadShell.id, [link(n)]]]))), + }), + ), + ), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + yield* Deferred.await(firstFillStarted); + + // A new session loads a second snapshot that reuses the bare row. + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 2, + ), + Stream.runHead, + ); + + // The superseded first fill finishes first, then the second. + yield* Deferred.succeed(firstLinks, undefined); + yield* Deferred.succeed(secondLinks, undefined); + const filled = yield* SubscriptionRef.changes(shellState).pipe( + Stream.map((state) => Option.getOrThrow(state.snapshot).threads[0]?.pullRequests), + Stream.filter((links) => links !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(filled).toEqual([link(2)]); + }), + ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 2c479489e318..dbc4405a9faa 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -3,10 +3,14 @@ import { type EnvironmentId, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, + type OrchestrationV2ThreadShell, type ServerConfig, + type ThreadId, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -25,7 +29,13 @@ import { runCachePersistence } from "./cachePersistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; import type { RpcSession } from "../rpc/session.ts"; import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; -import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts"; +import type { DeferredShellSnapshot } from "./shellPullRequests.ts"; +import { + applyShellStreamEvent, + mergeShellSnapshotProjects, + reuseUnchangedThreadShells, + sameThreadPullRequests, +} from "./shellReducer.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { followStreamInEnvironment } from "./runtime.ts"; @@ -49,6 +59,12 @@ function shellStatusForSnapshot( return Option.isSome(snapshot) ? "cached" : "empty"; } +function withoutDeferredPullRequests(value: DeferredShellSnapshot): OrchestrationV2ShellSnapshot { + if (value.loadPullRequests === undefined) return value; + const { loadPullRequests: _deferred, ...snapshot } = value; + return snapshot; +} + const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment data."; export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { @@ -57,17 +73,18 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const snapshotLoader = yield* ShellSnapshotLoader.ShellSnapshotLoader; const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; - const cachedSnapshot = yield* cache.loadShell(environmentId).pipe( + const cached = yield* cache.loadShell(environmentId).pipe( Effect.catch((error) => Effect.logWarning("Could not load cached environment shell.").pipe( Effect.annotateLogs({ environmentId, ...safeErrorLogAttributes(error), }), - Effect.as(Option.none()), + Effect.as(Option.none()), ), ), ); + const cachedSnapshot = Option.map(cached, withoutDeferredPullRequests); const state = yield* SubscriptionRef.make({ snapshot: cachedSnapshot, status: shellStatusForSnapshot(cachedSnapshot), @@ -83,12 +100,16 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const persistenceLock = yield* Semaphore.make(1); let lastPersisted: OrchestrationV2ShellSnapshot | undefined; + // Rows still waiting for their deferred pull request links, which must not be saved: + // the cache would keep them without links until the next server refresh. + let pendingLinkRows: ReadonlySet = new Set(); const persistLatest = Effect.fn("EnvironmentShellState.persistLatest")(function* ( flush: boolean, ) { while (true) { const latest = yield* Ref.get(latestLiveSnapshot); if (Option.isNone(latest) || latest.value === lastPersisted) return; + if (latest.value.threads.some((thread) => pendingLinkRows.has(thread))) return; const snapshot = latest.value; const saved = yield* cache.saveShell(environmentId, snapshot).pipe( Effect.as(true), @@ -155,11 +176,17 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ), ); + // Server items and deferred link fills both read the snapshot and write it back; one at a + // time, so neither can overwrite what the other wrote in between. + const snapshotWriteLock = yield* Semaphore.make(1); + // Apply each received batch with one state write. The RPC client's bounded // buffer can split a server chunk, so a bulk action can still need several // writes, but each write includes every event in that batch. const applyItems = Effect.fn("EnvironmentShellState.applyItems")(function* ( items: ReadonlyArray, + // Ids of rows whose links will fill later; they are held out of saves before one queues. + pendingLinkIds?: ReadonlySet, ) { const initial = yield* SubscriptionRef.get(state); let waiting = yield* Ref.get(awaitingCompletion); @@ -201,6 +228,11 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } yield* Ref.set(awaitingCompletion, waiting); if (next === initial) return; + if (pendingLinkIds !== undefined && Option.isSome(next.snapshot)) { + pendingLinkRows = new Set( + next.snapshot.value.threads.filter((thread) => pendingLinkIds.has(thread.id)), + ); + } if (Option.isSome(next.snapshot)) { yield* Ref.set(latestLiveSnapshot, next.snapshot); } @@ -214,7 +246,81 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") if (next.snapshot !== initial.snapshot && Option.isSome(next.snapshot)) { yield* Queue.offer(persistence, next.snapshot.value); } + }, snapshotWriteLock.withPermit); + + // Rows can arrive before their pull request links (see DeferredShellSnapshot), so the list + // paints first. The links then fill into the rows that still hold the object they arrived + // with; a row the server has replaced since carries its own links. + // Captured so fills started from the subscription callback still end with this state. + const stateScope = yield* Effect.scope; + // Each full snapshot supersedes the previous one's fill; links from an older snapshot must + // not land on rows a newer snapshot reused. A cancelled fill's rows stay pending until the + // next snapshot replaces the pending set. + let activeFill: Fiber.Fiber | undefined; + const cancelActiveFill = Effect.suspend(() => { + const fill = activeFill; + activeFill = undefined; + return fill === undefined ? Effect.void : Fiber.interrupt(fill); }); + const fillDeferredPullRequests = Effect.fnUntraced(function* ( + source: DeferredShellSnapshot, + applied: OrchestrationV2ShellSnapshot, + ) { + yield* cancelActiveFill; + if (source.loadPullRequests === undefined) return; + const arrivedRows = new Set(applied.threads); + const arrivedIds = new Set(source.threads.map((thread) => thread.id)); + pendingLinkRows = new Set(applied.threads.filter((thread) => arrivedIds.has(thread.id))); + activeFill = yield* source.loadPullRequests.pipe( + Effect.flatMap((linksByThreadId) => + writeFilledLinks(linksByThreadId, arrivedRows, arrivedIds), + ), + Effect.forkIn(stateScope), + ); + }); + const writeFilledLinks = Effect.fnUntraced( + function* ( + linksByThreadId: ReadonlyMap>, + arrivedRows: ReadonlySet, + arrivedIds: ReadonlySet, + ) { + const filled = yield* SubscriptionRef.modify( + state, + (current): [Option.Option, EnvironmentShellState] => { + if (Option.isNone(current.snapshot)) return [Option.none(), current]; + const snapshot = current.snapshot.value; + let changed = false; + const threads = snapshot.threads.map((thread) => { + if (!arrivedRows.has(thread) || !arrivedIds.has(thread.id)) return thread; + const links = linksByThreadId.get(thread.id); + // A reused row may already hold these links from an earlier fill. + if (sameThreadPullRequests(thread.pullRequests, links)) return thread; + changed = true; + if (links === undefined) { + const { pullRequests: _previous, ...row } = thread; + return row; + } + return { ...thread, pullRequests: links }; + }); + if (!changed) return [Option.none(), current]; + const next = { ...snapshot, threads }; + return [Option.some(next), { ...current, snapshot: Option.some(next) }]; + }, + ); + // Saves skipped while links were pending are queued again. Cached rows are never live: + // until a server snapshot arrives there is nothing new to save. + pendingLinkRows = new Set(); + const live = yield* Ref.get(latestLiveSnapshot); + if (Option.isNone(live)) return; + const latest = Option.isSome(filled) ? filled : live; + yield* Ref.set(latestLiveSnapshot, latest); + yield* Queue.offer(persistence, latest.value); + }, + (effect) => snapshotWriteLock.withPermit(effect), + ); + if (Option.isSome(cached) && Option.isSome(cachedSnapshot)) { + yield* fillDeferredPullRequests(cached.value, cachedSnapshot.value); + } const foregroundResubscriptions = Option.match(wakeups, { onNone: () => Stream.never, @@ -258,9 +364,23 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ); const httpSnapshot = yield* snapshotLoader.load(prepared); if (Option.isSome(httpSnapshot)) { - yield* applyItems([{ kind: "snapshot", snapshot: httpSnapshot.value }]); + yield* cancelActiveFill; + const previous = yield* SubscriptionRef.get(state); + const snapshot = reuseUnchangedThreadShells( + Option.getOrNull(previous.snapshot), + withoutDeferredPullRequests(httpSnapshot.value), + ); + const pendingLinkIds = new Set( + httpSnapshot.value.loadPullRequests === undefined + ? [] + : httpSnapshot.value.threads.map((thread) => thread.id), + ); + yield* applyItems([{ kind: "snapshot", snapshot }], pendingLinkIds); canResume = true; current = yield* SubscriptionRef.get(state); + if (Option.isSome(current.snapshot)) { + yield* fillDeferredPullRequests(httpSnapshot.value, current.snapshot.value); + } } } @@ -288,7 +408,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") retryExpectedFailureAfter: "250 millis", resubscribe: foregroundResubscriptions, }, - ).pipe(Stream.runForEachArray(applyItems)), + ).pipe(Stream.runForEachArray((items) => applyItems(items))), ); yield* SubscriptionRef.changes(supervisor.state).pipe( Stream.runForEach((connectionState) => { @@ -449,5 +569,6 @@ export function createEnvironmentShellAtoms( export * from "./models.ts"; export * from "./shellCommands.ts"; export * from "./shellReducer.ts"; +export * from "./shellPullRequests.ts"; export * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; export * from "./snapshots.ts"; diff --git a/packages/client-runtime/src/state/shellPullRequests.ts b/packages/client-runtime/src/state/shellPullRequests.ts new file mode 100644 index 000000000000..aedc90d25d90 --- /dev/null +++ b/packages/client-runtime/src/state/shellPullRequests.ts @@ -0,0 +1,97 @@ +import { + OrchestrationV2ShellSnapshot, + type ThreadId, + ThreadPullRequestLink, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +/** + * A shell snapshot whose threads may arrive before their pull request links are decoded. Those + * links are most of a large shell's decode cost and only feed PR badges, so the thread list + * can paint first. `loadPullRequests` then decodes the links by thread id; until it runs, + * those threads carry no `pullRequests`. + */ +export interface DeferredShellSnapshot extends OrchestrationV2ShellSnapshot { + readonly loadPullRequests?: Effect.Effect< + ReadonlyMap> + >; +} + +// A zero sleep is a microtask yield, which on React Native runs before the next frame. +// A 1ms sleep takes a host timer, so the rows can render before the deferred decode. +const yieldToHost = Effect.sleep("1 millis"); + +const decodeThreadLinks = Schema.decodeUnknownEffect( + Schema.UndefinedOr(Schema.toCodecJson(Schema.Array(ThreadPullRequestLink))), +); +// Bounds each uninterrupted stretch of decoding, so taps and frames run between batches. +const LINKS_PER_BATCH = 400; + +/** + * Removes each parsed thread row's pull request links from `snapshot` in place and returns + * them in row order, for `deferPullRequests` once the rows are decoded. + */ +export function detachPullRequests(snapshot: unknown): ReadonlyArray { + if (!Predicate.isObject(snapshot)) return []; + const rows: unknown = snapshot.threads; + if (!Array.isArray(rows)) return []; + return rows.map((row: unknown) => { + if (!Predicate.isObject(row)) return undefined; + const links = row.pullRequests; + delete row.pullRequests; + return links; + }); +} + +/** + * Attaches a `loadPullRequests` that decodes the detached links after yielding to the host. + * A thread whose links cannot be read gets none; its row stays usable. + */ +export function deferPullRequests( + snapshot: S, + rawLinks: ReadonlyArray, +): S & Required> { + const threadIds = snapshot.threads.map((thread) => thread.id); + // Schema decoding runs when called, so each thread's decode is built after a yield. + const loadPullRequests = Effect.gen(function* () { + const linksByThreadId = new Map>(); + let batchSize = LINKS_PER_BATCH; + for (const [index, threadId] of threadIds.entries()) { + const raw = rawLinks[index]; + if (raw === undefined) continue; + const size = Array.isArray(raw) ? raw.length : 1; + if (batchSize + size > LINKS_PER_BATCH) { + yield* yieldToHost; + batchSize = 0; + } + batchSize += size; + // An unreadable thread keeps no links; the others still fill in. + const links = yield* decodeThreadLinks(raw).pipe( + Effect.catch((cause) => + Effect.logWarning("Discarding unreadable shell pull request links.", { + threadId, + cause: String(cause), + }).pipe(Effect.as(undefined)), + ), + ); + if (links !== undefined) linksByThreadId.set(threadId, links); + } + return linksByThreadId as ReadonlyMap>; + }); + return { ...snapshot, loadPullRequests }; +} + +const decodeShellSnapshotJson = Schema.decodeUnknownEffect( + Schema.toCodecJson(OrchestrationV2ShellSnapshot), +); + +/** Decodes a JSON shell snapshot with its threads' pull request links deferred. */ +export const decodeShellSnapshotDeferringPullRequests = Effect.fnUntraced(function* ( + json: unknown, +) { + const rawLinks = detachPullRequests(json); + const snapshot = yield* decodeShellSnapshotJson(json); + return deferPullRequests(snapshot, rawLinks); +}); diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 7a3bd4c75254..b7a69b2a5152 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -1,8 +1,12 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; -import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts"; +import { + applyShellStreamEvent, + mergeShellSnapshotProjects, + reuseUnchangedThreadShells, +} from "./shellReducer.ts"; const repositoryIdentity = { canonicalKey: "github.com/example/repo", @@ -463,3 +467,39 @@ describe("applyShellStreamEvent", () => { expect(next).toBe(v2ShellSnapshot); }); }); + +describe("reuseUnchangedThreadShells", () => { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 3, + url: "https://github.com/pingdotgg/t3code/pull/3", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const linked = { ...v2ThreadShell, pullRequests: [link] }; + const other = { ...v2ThreadShell, id: ThreadId.make("thread-other"), title: "Other" }; + + it("keeps the previous object for an unchanged row, even without its deferred links", () => { + const previous = { ...v2ShellSnapshot, threads: [linked, other] }; + const { pullRequests: _links, ...withoutLinks } = linked; + const next = reuseUnchangedThreadShells(previous, { + ...v2ShellSnapshot, + snapshotSequence: 5, + threads: [withoutLinks, { ...other, title: "Renamed" }], + }); + expect(next.snapshotSequence).toBe(5); + expect(next.threads[0]).toBe(linked); + expect(next.threads[1]).not.toBe(other); + expect(next.threads[1]?.title).toBe("Renamed"); + }); + + it("returns the incoming snapshot when no row is unchanged", () => { + const incoming = { ...v2ShellSnapshot, threads: [{ ...other, title: "Renamed" }] }; + expect(reuseUnchangedThreadShells({ ...v2ShellSnapshot, threads: [other] }, incoming)).toBe( + incoming, + ); + }); +}); diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index 331b271d6eb7..f99f5e7681a4 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -1,5 +1,6 @@ import { OrchestrationV2ThreadShell, + ThreadPullRequestLink, type OrchestrationProjectShell, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, @@ -7,6 +8,41 @@ import { import * as Schema from "effect/Schema"; const sameThreadShell = Schema.toEquivalence(OrchestrationV2ThreadShell); +const sameLinks = Schema.toEquivalence(Schema.UndefinedOr(Schema.Array(ThreadPullRequestLink))); + +/** Whether two rows' pull request links are equal, including both absent. */ +export function sameThreadPullRequests( + left: OrchestrationV2ThreadShell["pullRequests"], + right: OrchestrationV2ThreadShell["pullRequests"], +): boolean { + return left === right || sameLinks(left, right); +} + +/** + * Keep the previous object for each row a full snapshot left unchanged, so the list only + * re-renders rows that changed. Links are compared apart from the rest of the row because a + * deferred snapshot arrives without them; such a row keeps its links until they fill in. + */ +export function reuseUnchangedThreadShells( + previous: OrchestrationV2ShellSnapshot | null, + next: OrchestrationV2ShellSnapshot, +): OrchestrationV2ShellSnapshot { + if (previous === null || previous.threads.length === 0) return next; + const previousById = new Map(previous.threads.map((thread) => [thread.id, thread] as const)); + let reused = 0; + const threads = next.threads.map((thread) => { + const prior = previousById.get(thread.id); + if (prior === undefined) return thread; + const candidate = + thread.pullRequests === undefined && prior.pullRequests !== undefined + ? { ...thread, pullRequests: prior.pullRequests } + : thread; + if (!sameThreadShell(prior, candidate)) return thread; + reused += 1; + return prior; + }); + return reused === 0 ? next : { ...next, threads }; +} function upsertById( items: ReadonlyArray, diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index c4154e009c78..14e57be1fa67 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -1,10 +1,11 @@ -import type { OrchestrationV2ShellSnapshot } from "@t3tools/contracts"; +import { EnvironmentHttpCommonError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { HttpClient } from "effect/http"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientError, type HttpClientResponse } from "effect/http"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import type { PreparedConnection } from "../connection/model.ts"; @@ -14,6 +15,10 @@ import { executeAuthenticatedEnvironmentHttpRequest, withOrchestrationProtocolHeader, } from "./environmentHttpAuth.ts"; +import { + decodeShellSnapshotDeferringPullRequests, + type DeferredShellSnapshot, +} from "./shellPullRequests.ts"; // Long enough for a slow but alive server to finish. On timeout the socket asks // the same server for the same full snapshot, so a short deadline only throws @@ -45,11 +50,45 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( method: "GET", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"), timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, + // The body is decoded here so each thread's pull request links can decode after its + // rows. Every declared error is an `EnvironmentHttpCommonError`, so decoding one keeps + // the error the credential retry checks for. request: ({ client, headers }) => - client.shellSnapshot({ headers: withOrchestrationProtocolHeader(headers) }), + client + .shellSnapshot({ + headers: withOrchestrationProtocolHeader(headers), + responseMode: "response-only", + }) + .pipe(Effect.flatMap(decodeShellSnapshotResponse)), }); }); +const decodeEnvironmentHttpCommonError = Schema.decodeUnknownEffect( + Schema.toCodecJson(EnvironmentHttpCommonError), +); + +const decodeShellSnapshotResponse = ( + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect< + DeferredShellSnapshot, + HttpClientError.HttpClientError | Schema.SchemaError | EnvironmentHttpCommonError +> => { + if (response.status === 200) { + return response.json.pipe(Effect.flatMap(decodeShellSnapshotDeferringPullRequests)); + } + // A body that is not a declared error keeps its status, as the generated decoder reported. + const statusError = new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request: response.request, response }), + }); + return response.json.pipe( + Effect.flatMap(decodeEnvironmentHttpCommonError), + Effect.matchEffect({ + onFailure: () => Effect.fail(statusError), + onSuccess: Effect.fail, + }), + ); +}; + /** * Loads the environment shell snapshot over HTTP, returning `Option.none()` when * it cannot be loaded (so the caller falls back to the socket-embedded snapshot). @@ -61,7 +100,7 @@ export class ShellSnapshotLoader extends Context.Service< { readonly load: ( prepared: PreparedConnection, - ) => Effect.Effect>; + ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/shellSnapshotHttp/ShellSnapshotLoader") {} @@ -78,14 +117,14 @@ export const layer: Layer.Layer fetchEnvironmentShellSnapshot({ prepared, signer, remoteAuthorization }).pipe( - Effect.map(Option.some), + Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.catchCause((cause) => Effect.logWarning( "Could not load the environment shell snapshot over HTTP; using the socket snapshot instead.", ).pipe( Effect.annotateLogs({ cause: Cause.pretty(cause) }), - Effect.as(Option.none()), + Effect.as(Option.none()), ), ), ), From 9fba2093e50f208c52d9041c70a793296e311477 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:36:42 -0400 Subject: [PATCH 003/273] fix(server): replaying a command no longer freezes the server (#17041) Co-authored-by: Claude Opus 5.5 (1M context) --- .../OrchestrationEventStore.sequence.test.ts | 58 +++++++++++++++++++ .../persistence/OrchestrationEventStore.ts | 10 +++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts index 82d4838fb27a..9c7f87b3c9b8 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts @@ -272,3 +272,61 @@ it.effect("uses indexed high-water lookups for populated history without OR scan ), ), ); + +it.effect("replays a command's events through the command index, not the sequence range", () => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + yield* sql` + WITH RECURSIVE history(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000 + ) + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, + command_id, actor_kind, payload_json, metadata_json, application_event_version + ) + SELECT 'history:' || n, 'thread', 'thread', n, 'provider-session.detached', ${occurredAt}, + CASE WHEN n IN (12, 24990) THEN 'retried-command' ELSE 'command:' || n END, + 'server', + '{"providerSessionId":"session","detachedAt":"' || ${occurredAt} || '"}', + '{}', 2 + FROM history + `; + const statements: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query === "string") statements.push(query); + }; + return span; + }, + }); + const replayed = yield* store + .readAgentEvents({ commandId: CommandId.make("retried-command") }) + .pipe(Stream.runCollect, Effect.withTracer(tracer)); + assert.deepEqual( + replayed.map((event) => event.sequence), + [12, 24990], + ); + assert.equal(statements.length, 1); + const plan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${statements[0]}`, + [0, Number.MAX_SAFE_INTEGER, "retried-command", 500], + ); + assert.match( + plan.map((row) => row.detail).join("\n"), + /SEARCH orchestration_events USING INDEX idx_orch_events_command_id \(command_id=\?/, + ); + }).pipe( + Effect.provide( + OrchestrationEventStore.layer.pipe( + Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), + ), + ), + ), +); diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts index 588defc0d6ca..ef8b49c2c53f 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -407,9 +407,13 @@ const makeEventStore = Effect.gen(function* () { correlation_id FROM orchestration_events ${ - input.onlyAgentEvents === true - ? sql`` - : sql`INDEXED BY idx_orchestration_events_application_high_water` + // The sequence range otherwise wins and a command lookup scans every + // V2 event, which blocks the server for seconds on large histories. + input.commandId !== undefined + ? sql`INDEXED BY idx_orch_events_command_id` + : input.onlyAgentEvents === true + ? sql`` + : sql`INDEXED BY idx_orchestration_events_application_high_water` } WHERE sequence > ${input.afterSequence} AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER} From 04ad17425a80484bf323a7d5076bd477a9499119 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:16:52 -0400 Subject: [PATCH 004/273] fix(server): opening a long thread no longer blocks the server for a second (#17029) Co-authored-by: Claude Opus 5.5 (1M context) --- .../orchestration-v2/ProjectionStore.test.ts | 44 +++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 24 ++++++---- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/055_OrchestrationV2.test.ts | 4 +- .../060_ThreadSnapshotWindowIndexes.ts | 23 ++++++++++ .../reconcileV2PreviewMigration.test.ts | 2 + 6 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index f47b4d6829e8..194d7507f2e6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -27,6 +27,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; @@ -588,12 +589,26 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }); yield* sql`INSERT INTO orchestration_v2_projection_turn_items ${sql.insert(rows)}`; } + const statements: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query === "string") statements.push(query); + }; + return span; + }, + }); const initial = yield* Effect.acquireUseRelease( Effect.sync(() => vi.spyOn(JSON, "parse")), (parse) => projectionStore .getThreadSnapshotWindow(threadId, { rowLimit: 77, userTurnLimit: 10 }) .pipe( + Effect.withTracer(tracer), Effect.tap(() => Effect.sync(() => { // Tool outputs must not be allocated again just to collect cohort IDs. @@ -606,6 +621,35 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ), (parse) => Effect.sync(() => parse.mockRestore()), ); + // Large threads blocked the server for over a second here. The boundary + // must come from the user-message index rather than a scan of every tool + // row, and payloads must not go through a sort after they are fetched. + const windowStatement = statements.find((statement) => statement.includes("turn_anchors")); + assert.isDefined(windowStatement); + const windowPlan = yield* sql.unsafe<{ readonly parent: number; readonly detail: string }>( + `EXPLAIN QUERY PLAN ${windowStatement}`, + ); + assert.match( + windowPlan.map((row) => row.detail).join("\n"), + /SEARCH item USING INDEX orchestration_v2_projection_turn_items_user_message_idx \(thread_id=\? AND ordinal<\?\)/, + ); + const topLevel = windowPlan.filter((row) => row.parent === 0).map((row) => row.detail); + assert.include( + topLevel, + "SEARCH item USING INDEX sqlite_autoindex_orchestration_v2_projection_turn_items_1 (turn_item_id=?)", + ); + assert.notInclude(topLevel, "USE TEMP B-TREE FOR ORDER BY"); + const nodeStatement = statements.find((statement) => + statement.includes("WITH RECURSIVE retained"), + ); + assert.isDefined(nodeStatement); + const nodePlan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${nodeStatement}`, + ); + assert.include( + nodePlan.map((row) => row.detail), + "SEARCH orchestration_v2_projection_nodes USING INDEX orchestration_v2_projection_nodes_live_idx (thread_id=?)", + ); // Only the selected turn cohort and two lookahead anchors are decoded. assert.lengthOf(initial.projection.turnItems, 12 * 102); const bounded = buildBoundedThreadProjection({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 07d847e2b91d..4da19774075b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2762,7 +2762,10 @@ export const layer: Layer.Layer = END AS ordinal FROM user_anchors ), selected AS ( - SELECT payload_json, ordinal, turn_item_id, run_id, type + -- Choose and sort rows by ID, then fetch payloads in that + -- order. A turn window can hold megabytes of tool output, and + -- carrying it through the union and sort cost about a second. + SELECT ordinal, turn_item_id, run_id, type FROM eligible WHERE ordinal >= (SELECT ordinal FROM boundary) ORDER BY ordinal DESC, turn_item_id DESC @@ -2771,10 +2774,10 @@ export const layer: Layer.Layer = WHEN (SELECT COUNT(*) FROM user_anchors) > 0 THEN -1 ELSE ${window.rowLimit} END - ), retained AS ( - SELECT payload_json, ordinal, turn_item_id FROM selected + ), retained AS MATERIALIZED ( + SELECT ordinal, turn_item_id FROM selected UNION - SELECT request.payload_json, request.ordinal, request.turn_item_id + SELECT request.ordinal, request.turn_item_id FROM orchestration_v2_projection_turn_items AS request WHERE request.run_id IN ( SELECT run_id FROM selected @@ -2782,9 +2785,9 @@ export const layer: Layer.Layer = ) AND request.type = 'run_interrupt_request' UNION - SELECT latest.payload_json, latest.ordinal, latest.turn_item_id + SELECT latest.ordinal, latest.turn_item_id FROM ( - SELECT payload_json, ordinal, turn_item_id + SELECT ordinal, turn_item_id FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND ${window.anchorItemId ?? null} IS NULL @@ -2792,10 +2795,15 @@ export const layer: Layer.Layer = ORDER BY ordinal DESC, turn_item_id DESC LIMIT 1 ) AS latest + ORDER BY ordinal ASC, turn_item_id ASC ) - SELECT payload_json + -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite + -- skips sorting again once the payloads are attached. + SELECT item.payload_json FROM retained - ORDER BY ordinal ASC, turn_item_id ASC + CROSS JOIN orchestration_v2_projection_turn_items AS item + ON item.turn_item_id = retained.turn_item_id + ORDER BY retained.ordinal ASC, retained.turn_item_id ASC `; // Reuse the decoded items for cohort IDs and the resulting projection. // Parsing these rows separately duplicates every retained tool output. diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1e1f207a7dac..35c1a3fdce4b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -73,6 +73,7 @@ import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts" import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts"; import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; +import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -146,6 +147,7 @@ export const migrationEntries = [ [57, "ScheduledTaskWebhooks", Migration0057], [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], + [60, "ThreadSnapshotWindowIndexes", Migration0060], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 5f2e05f2809f..5e45ed05d302 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 59 }, (_, index) => index + 1), + Array.from({ length: 60 }, (_, index) => index + 1), ); }), ); @@ -31,6 +31,7 @@ layer("055_OrchestrationV2", (it) => { [57, "ScheduledTaskWebhooks"], [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], + [60, "ThreadSnapshotWindowIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -56,6 +57,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 57, name: "ScheduledTaskWebhooks" }, { migration_id: 58, name: "WebhookRelayDeliveries" }, { migration_id: 59, name: "McpAppModelContext" }, + { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, ]); const tables = yield* sql<{ readonly name: string }>` diff --git a/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts b/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts new file mode 100644 index 000000000000..6977a165a303 --- /dev/null +++ b/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // A bounded thread snapshot finds its turn boundary from the newest user + // messages. Without this index it reads every turn item in the thread to + // find a few dozen of them. ProjectionStore's turn_anchors CTE relies on it. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_user_message_idx + ON orchestration_v2_projection_turn_items(thread_id, ordinal, turn_item_id) + WHERE type = 'user_message' + `; + // The same snapshot keeps every unfinished node. A long thread has thousands + // of finished ones, and reading them all to find a few live ones was more + // than half of the node query's time. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_nodes_live_idx + ON orchestration_v2_projection_nodes(thread_id) + WHERE status IN ('pending', 'starting', 'running', 'waiting') + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 826dc4831d8d..675d5f5db021 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -40,6 +40,7 @@ describe("V2 preview upgrade", () => { [57, "ScheduledTaskWebhooks"], [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], + [60, "ThreadSnapshotWindowIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -122,6 +123,7 @@ describe("V2 preview upgrade", () => { [57, "ScheduledTaskWebhooks"], [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], + [60, "ThreadSnapshotWindowIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 0f09627a111f7e9adc22c256c725565672676f1a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 20:59:26 -0700 Subject: [PATCH 005/273] fix(server,web,mobile): thread links reference the thread id, not a baked title (#17017) --- .../src/features/threads/ThreadFeed.tsx | 30 +++-- .../mobile/src/lib/nativeMarkdownText.test.ts | 4 +- apps/mobile/src/state/entities.ts | 36 +++++ apps/server/scripts/migrate-dev-db.test.ts | 59 ++++++++ apps/server/scripts/migrate-dev-db.ts | 126 ++++++++++++++---- apps/server/src/mcp/OrchestratorMcpService.ts | 35 +---- ...OrchestratorMcpToolkit.integration.test.ts | 3 - .../src/mcp/toolkits/orchestrator/tools.ts | 4 +- .../src/mcp/toolkits/project/handlers.test.ts | 16 +-- .../src/mcp/toolkits/project/handlers.ts | 8 +- apps/server/src/mcp/toolkits/project/tools.ts | 4 +- .../provider/T3OrchestrationInstructions.ts | 1 + apps/web/src/components/ChatMarkdown.tsx | 15 ++- .../components/chat/MarkdownThreadLink.tsx | 28 ++-- .../orchestrator-mcp-server.md | 9 +- packages/contracts/src/orchestratorMcp.ts | 4 - packages/shared/src/threadLinks.test.ts | 85 +++++++++--- packages/shared/src/threadLinks.ts | 97 ++++++++------ 18 files changed, 384 insertions(+), 180 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b5f258a0889f..b2e10812cb5e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -198,6 +198,7 @@ import { } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; +import { useLiveThreadLinkLabels } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; import * as Option from "effect/Option"; @@ -906,16 +907,15 @@ interface MarkdownLinkHandlers { const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; + readonly environmentId: EnvironmentId; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; }) { - const segments = useMemo( - () => splitCodexArtifactTemplateMarkdown(props.markdown), - [props.markdown], - ); + const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); + const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]); return segments.map((segment) => { if (segment.kind === "artifact-template") { @@ -1905,6 +1905,7 @@ function renderFeedEntry( (null); const navigation = useNavigation(); const { selectedThread } = useThreadSelection(); - const text = replaceComposerContextReferences(props.text, (ref) => { + const liveText = useLiveThreadLinkLabels(props.text, props.environmentId); + const text = replaceComposerContextReferences(liveText, (ref) => { const available = props.context?.records.some((record) => record.contextId === ref.contextId); return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`; }); @@ -2292,11 +2294,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { - const threadLink = parseThreadLinkHref(href); - if (threadLink) { + // A thread link names a thread in this feed's environment. + const linkedThreadId = parseThreadLinkHref(href); + if (linkedThreadId) { navigation.navigate("Thread", { - environmentId: String(threadLink.environmentId), - threadId: String(threadLink.threadId), + environmentId: String(props.environmentId), + threadId: String(linkedThreadId), }); return; } @@ -2504,13 +2507,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (text: string) => ( ), - [markdownStyles.assistant, markdownLinkHandlers, renderMarkdownImage, props.skills], + [ + markdownStyles.assistant, + markdownLinkHandlers, + renderMarkdownImage, + props.skills, + props.environmentId, + ], ); const reviewCommentColors = useReviewCommentColors(); const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null; diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 62f0de0c1b95..e43685407f85 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -91,12 +91,12 @@ describe("nativeMarkdownTextRuns", () => { children: [ { type: "link", - href: "t3-thread://v1/env/thread-1", + href: "t3-thread://v1/thread-1", children: [{ type: "text", content: "Fix the build" }], }, ], }), - ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]); + ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/thread-1" }]); }); it("preserves the destination of a link with a code-formatted label", () => { diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 94ab662c7291..0d3b33d582d5 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -12,8 +12,11 @@ import type { ScopedProjectRef, ScopedThreadRef, ServerConfig, + ThreadId, } from "@t3tools/contracts"; +import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks"; import { Atom } from "effect/reactivity"; +import { useMemo } from "react"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom, serverEnvironment } from "./server"; @@ -28,6 +31,25 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).p const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-server-config:empty"), ); +const EMPTY_THREAD_TITLES: ReadonlyMap = new Map(); +const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe( + Atom.withLabel("mobile-thread-titles:empty"), +); + +/** Thread titles in one environment. Emits when a title changes, not on every shell update. */ +const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => { + let previous = EMPTY_THREAD_TITLES; + return Atom.make((get) => { + const index = get(environmentThreadShells.environmentThreadIndexAtom(environmentId)); + const unchanged = + index.size === previous.size && + Array.from(index).every(([threadId, shell]) => previous.get(threadId) === shell.title); + if (!unchanged) { + previous = new Map(Array.from(index, ([threadId, shell]) => [threadId, shell.title])); + } + return previous; + }).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`)); +}); /** Resolves when the project event reaches the live client store. */ export function waitForProject( @@ -76,6 +98,20 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ +export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { + const titles = useAtomValue( + hasThreadLinks(markdown) ? threadTitlesAtom(environmentId) : EMPTY_THREAD_TITLES_ATOM, + ); + return useMemo( + () => + titles.size === 0 + ? markdown + : relabelThreadLinks(markdown, (threadId) => titles.get(threadId)), + [markdown, titles], + ); +} + export function useEnvironmentServerConfig( environmentId: EnvironmentId | null, ): ServerConfig | null { diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 731a67d94cb6..07d3dc61c791 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -142,6 +142,65 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { }), ); + it.effect("never copies a settled thread's rows, and new events append after the source's", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-dest-" }); + const source = yield* createFixtureSource(sourceDir); + const [sourceSequence] = yield* withDatabase( + source, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ seq: number }>` + SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`; + }), + ); + + // Keep every family, so only the copy can leave the settled one out. + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 100 }, + { sharedHome: sourceDir }, + ); + + const copied = yield* withDatabase( + result.databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const [settledRows] = yield* sql<{ count: number }>` + SELECT + (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'settled-thread') + + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'settled-thread') + AS count`; + const [sequence] = yield* sql<{ seq: number }>` + SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`; + return { settledRows: settledRows?.count, sequence: sequence?.seq }; + }), + ); + assert.equal(copied.settledRows, 0); + assert.equal(copied.sequence, sourceSequence?.seq); + }), + ); + + it.effect("upgrades a source from before the V2 thread tables", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-dest-" }); + const stateDir = path.join(sourceDir, "userdata"); + const source = path.join(stateDir, "statev2.sqlite"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* withDatabase(source, runMigrations({ toMigrationInclusive: 54 })); + + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ); + assert.include(result.executedMigrations, "55_OrchestrationV2"); + }), + ); + it.effect("fails loudly on a migration slot collision", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 3e9121fa2a9f..4cfdf6233346 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -6,9 +6,12 @@ * * `vp run migrate-dev-db` from a worktree: * 1. Nukes `/.t3/userdata/statev2.sqlite`. - * 2. Snapshots the real db (`~/.t3/userdata/statev2.sqlite`, read-only - * VACUUM INTO) and prunes it to the most recently updated projects and, - * per project, the most recent threads that have fully stopped, with + * 2. Copies a slice of the real db (`~/.t3/userdata/statev2.sqlite`, + * attached read-only): the schema, and every row except those of + * deleted, archived, and settled threads, which are nearly all of a + * long-lived database. It then prunes that to the most recently updated + * projects and, per project, the most recent threads that have fully + * stopped, with * their forks and subagents. Working, settled, and archived threads, and * threads with pending recovery, are skipped, and scheduled tasks and * queued effects are dropped, so the dev server never adopts live work. @@ -28,6 +31,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; @@ -242,6 +246,87 @@ const RECOVERY_KINDS: ReadonlyArray = [ "delegated-completions", ]; +/** Tables the prune empties. The copy skips their rows. */ +const CLEARED_TABLES: ReadonlyArray = [ + // Pending work the dev server would otherwise pick up and run. + "scheduled_tasks", + "orchestration_v2_effect_outbox", + "orchestration_v2_thread_launch_workflows", + "orchestration_command_receipts", + "provider_session_runtime", + "auth_sessions", + "auth_pairing_links", +]; + +/** Shared between threads; the prune keeps the ones a kept thread is bound to. */ +const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions"; + +/** Whether the thread aliased `root` is shown: not deleted, archived, or settled. */ +const rootIsVisible = (sql: SqlClient.SqlClient) => sql` + root.deleted_at IS NULL + AND json_extract(root.payload_json, '$.deletedAt') IS NULL + AND json_extract(root.payload_json, '$.archivedAt') IS NULL + AND json_extract(root.payload_json, '$.settledAt') IS NULL + AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'`; + +/** + * Copies the source into the empty snapshot, without the rows of thread families whose root is + * hidden. The prune drops those anyway, and they hold nearly all of a long-lived database's + * events, so copying them is what used to make this take a full-size copy of the real db. + * A source from before the V2 thread tables has no visibility to filter on, so it is copied + * whole. One transaction reads the source, so the copy is consistent while the real server writes. + */ +const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: string) { + const sql = yield* SqlClient.SqlClient; + yield* sql`ATTACH DATABASE ${`${NodeURL.pathToFileURL(sourcePath).href}?mode=ro`} AS src`; + const schema = yield* sql<{ type: string; name: string; sql: string }>` + SELECT type, name, sql FROM src.sqlite_master + WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'`; + const threadColumns = yield* sql<{ name: string; notnull: number }>` + SELECT m.name, c."notnull" FROM src.sqlite_master m, pragma_table_info(m.name, 'src') c + WHERE m.type = 'table' AND c.name = 'thread_id'`; + const threadIdNullable = new Map(threadColumns.map((row) => [row.name, row.notnull === 0])); + const tables = schema.filter((entry) => entry.type === "table"); + const filtered = tables.some((table) => table.name === "orchestration_v2_projection_threads"); + + yield* sql.withTransaction( + Effect.gen(function* () { + for (const table of tables) yield* sql.unsafe(table.sql).unprepared; + if (filtered) { + yield* sql`CREATE TEMP TABLE copied_threads (thread_id TEXT PRIMARY KEY)`; + yield* sql`INSERT OR IGNORE INTO copied_threads + SELECT family.thread_id FROM src.orchestration_v2_projection_threads family + JOIN src.orchestration_v2_projection_threads root ON root.thread_id = + COALESCE(json_extract(family.payload_json, '$.lineage.rootThreadId'), family.thread_id) + WHERE ${rootIsVisible(sql)}`; + } + for (const { name } of tables) { + if (CLEARED_TABLES.includes(name)) continue; + const nullable = threadIdNullable.get(name); + const filter = !filtered + ? "" + : name === "orchestration_events" + ? // Two ranges rather than `<>`, so SQLite seeks the (aggregate_kind, stream_id) + // index instead of scanning every event. + `WHERE (aggregate_kind = 'thread' AND stream_id IN (SELECT thread_id FROM copied_threads)) + OR aggregate_kind < 'thread' OR aggregate_kind > 'thread'` + : nullable === undefined || name === PROVIDER_SESSIONS_TABLE + ? "" + : `WHERE thread_id IN (SELECT thread_id FROM copied_threads)${nullable ? " OR thread_id IS NULL" : ""}`; + yield* sql.unsafe(`INSERT INTO main."${name}" SELECT * FROM src."${name}" ${filter}`) + .unprepared; + } + // The source's AUTOINCREMENT high-water marks, so new events append after them. + yield* sql`DELETE FROM main.sqlite_sequence`; + yield* sql`INSERT INTO main.sqlite_sequence SELECT name, seq FROM src.sqlite_sequence`; + for (const entry of schema) { + if (entry.type !== "table") yield* sql.unsafe(entry.sql).unprepared; + } + }), + ); + yield* sql`DETACH DATABASE src`; +}); + const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { const sql = yield* SqlClient.SqlClient; @@ -278,11 +363,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at FROM thread_families f JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id - WHERE root.deleted_at IS NULL - AND json_extract(root.payload_json, '$.deletedAt') IS NULL - AND json_extract(root.payload_json, '$.archivedAt') IS NULL - AND json_extract(root.payload_json, '$.settledAt') IS NULL - AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled' + WHERE ${rootIsVisible(sql)} GROUP BY f.root_id, root.project_id HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`; @@ -323,13 +404,13 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra const threadTables = yield* sql<{ name: string }>` SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c WHERE m.type = 'table' AND c.name = 'thread_id' - AND m.name <> 'orchestration_v2_projection_provider_sessions'`; + AND m.name <> ${PROVIDER_SESSIONS_TABLE}`; yield* sql.withTransaction( Effect.gen(function* () { yield* sql`DELETE FROM projection_projects WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`; - yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions + yield* sql`DELETE FROM ${sql(PROVIDER_SESSIONS_TABLE)} WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads) AND provider_session_id NOT IN ( SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings @@ -348,14 +429,9 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra AND stream_id NOT IN (SELECT thread_id FROM kept_threads)) OR (aggregate_kind = 'project' AND stream_id NOT IN (SELECT project_id FROM kept_projects))`; - // Pending work the dev server would otherwise pick up and run. - yield* sql`DELETE FROM scheduled_tasks`; - yield* sql`DELETE FROM orchestration_v2_effect_outbox`; - yield* sql`DELETE FROM orchestration_v2_thread_launch_workflows`; - yield* sql`DELETE FROM orchestration_command_receipts`; - yield* sql`DELETE FROM provider_session_runtime`; - yield* sql`DELETE FROM auth_sessions`; - yield* sql`DELETE FROM auth_pairing_links`; + for (const table of CLEARED_TABLES) { + yield* sql`DELETE FROM ${sql(table)}`; + } }), ); @@ -457,22 +533,16 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( ); yield* removeDatabaseFiles(snapshotPath); - // The snapshot is a full-size copy of the source; make sure it is removed - // even when a phase fails partway through. + // Make sure the snapshot is removed even when a phase fails partway through. const { executedMigrations, pruned } = yield* Effect.gen(function* () { - yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`); - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`VACUUM INTO ${snapshotPath}`; - }).pipe( - Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })), + yield* Console.log(`Copying visible threads from ${sourcePath} (read-only)...`); + yield* copySourceSlice(sourcePath).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), wrapPhase("snapshot", sourcePath), ); // Migrate before pruning: a source older than this checkout would // otherwise crash the prune queries on columns that don't exist yet. - // Running against the full snapshot also exercises new migrations on the - // same data volume the real database would face. yield* Console.log("Running migrations on the snapshot..."); const executed = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index dccda1b49519..ebb1aa9e4c1a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,5 +1,4 @@ import { - type EnvironmentId, CommandId, type RunId, isProviderAvailable, @@ -59,7 +58,6 @@ import { ThreadId, } from "@t3tools/contracts"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -633,23 +631,12 @@ function threadSnooze( }; } -/** Where and when a thread is shown: the environment for its link, and the time for snooze state. */ -interface ThreadViewContext { - readonly environmentId: EnvironmentId; - readonly nowMs: number; -} - function listItemFromShell( shell: OrchestrationV2ThreadShell, - context: ThreadViewContext, + nowMs: number, ): OrchestratorMcpThreadListItem { return { threadId: shell.id, - link: formatThreadLink({ - environmentId: context.environmentId, - threadId: shell.id, - title: shell.title, - }), title: shell.title, createdBy: shell.createdBy, creationSource: shell.creationSource, @@ -661,7 +648,7 @@ function listItemFromShell( interactionMode: shell.interactionMode, linkedPullRequest: shell.linkedPullRequest ?? null, ...threadSettlement(shell), - ...threadSnooze(shell, context.nowMs), + ...threadSnooze(shell, nowMs), parentThreadId: shell.lineage.parentThreadId, relationshipToParent: shell.lineage.relationshipToParent, itemCount: shell.visibleItemCount, @@ -674,17 +661,12 @@ function threadDetail( projection: Pick, itemCount: number, shell: OrchestrationV2ThreadShell, - context: ThreadViewContext, + nowMs: number, ): OrchestratorMcpThreadDetail { const latest = ThreadManagementService.latestRun(projection); const active = ThreadManagementService.latestActiveRun(projection); return { threadId: projection.thread.id, - link: formatThreadLink({ - environmentId: context.environmentId, - threadId: projection.thread.id, - title: projection.thread.title, - }), projectId: projection.thread.projectId, title: projection.thread.title, createdBy: projection.thread.createdBy, @@ -717,7 +699,7 @@ function threadDetail( archived: projection.thread.archivedAt !== null, ...threadSettlement(projection.thread), // From the shell, like the list, so read and list agree on snooze state. - ...threadSnooze(shell, context.nowMs), + ...threadSnooze(shell, nowMs), createdAt: DateTime.formatIso(projection.thread.createdAt), updatedAt: DateTime.formatIso(projection.thread.updatedAt), }; @@ -2293,9 +2275,7 @@ const make = Effect.gen(function* () { return { projectId, currentThreadId: parent?.thread.id ?? null, - threads: page.map((shell) => - listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }), - ), + threads: page.map((shell) => listItemFromShell(shell, nowMs)), nextCursor, total: filtered.length, } satisfies OrchestratorMcpThreadListResult; @@ -2375,10 +2355,7 @@ const make = Effect.gen(function* () { } } return { - thread: threadDetail(target, timeline.totalItems, shell, { - environmentId: scope.environmentId, - nowMs: yield* Clock.currentTimeMillis, - }), + thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis), recentRuns: target.runs .toSorted((left, right) => right.ordinal - left.ordinal) .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 673830dd66d3..e59f8fff9a05 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -2233,9 +2233,6 @@ describe("orchestrator MCP toolkit", () => { }); expect(metadataRead.thread).toMatchObject({ snoozed: false, snoozedUntil: null }); - expect(metadataRead.thread.link).toBe( - `[Metadata-managed thread](t3-thread://v1/environment%3Amcp-orchestrator/${encodeURIComponent(emptyThread.threadId)})`, - ); yield* orchestrator.dispatch({ type: "thread.snooze", commandId: CommandId.make("command:mcp-empty:snooze"), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 256b08638ac0..e7ef07101a39 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early when it asks for something, fails, or completes. Each thread has a link: paste it when you mention the thread so the user can open it.", + "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early when it asks for something, fails, or completes. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread has a link and snooze state: paste the link when you mention the thread so the user can open it.", + "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 255829ff094c..d657938ff206 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread", return Effect.succeed({ threadId: input.threadId, projection: { - thread: { id: input.threadId, projectId, modelSelection, title: input.title }, + thread: { id: input.threadId, projectId, modelSelection }, runs: [], }, resumed: false, @@ -94,11 +94,7 @@ it.effect("attributes a launched thread's first message to the calling thread", const result = yield* toolkit .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - expect(result.at(-1)?.result).toMatchObject({ - projectId, - modelSelection, - link: expect.stringMatching(/^\[Audit\]\(t3-thread:\/\/v1\/environment\//), - }); + expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); expect(launchedSender).toBe(sourceThreadId); }), ); @@ -144,12 +140,7 @@ it.effect("launches a scratch thread into the Scratch project", () => return Effect.succeed({ threadId: input.threadId, projection: { - thread: { - id: input.threadId, - projectId: input.projectId, - modelSelection, - title: input.title, - }, + thread: { id: input.threadId, projectId: input.projectId, modelSelection }, runs: [], }, resumed: false, @@ -338,7 +329,6 @@ const clientLaunchHarness = (input: { id: launch.threadId, projectId: launch.projectId, modelSelection: launch.modelSelection, - title: launch.title, }, runs: [], }, diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 4f3b66e8dc70..9a7d7024111e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,5 +1,4 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -58,7 +57,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { const context = yield* readCaller(); - const { caller, scope } = context; + const { caller } = context; const commandId = yield* newCommandId(); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); @@ -147,11 +146,6 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const run = result.projection.runs.find((run) => run.userMessageId === messageId); return { threadId: thread.id, - link: formatThreadLink({ - environmentId: scope.environmentId, - threadId: thread.id, - title: thread.title, - }), projectId: thread.projectId, modelSelection: thread.modelSelection, runId: run?.id ?? null, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index bb81b8c83e0e..ee489d4e104c 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. Paste the returned link when you mention the thread. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -132,8 +132,6 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), success: Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, projectId: ProjectId, modelSelection: ModelSelection, runId: Schema.NullOr(RunId), diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index fe700c0e2084..2280d4d25619 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -11,6 +11,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. +- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title. ### Choose the workspace before starting a new thread diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e595efdc5a07..d926f5aaf412 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3167,13 +3167,14 @@ const CHAT_MARKDOWN_COMPONENTS = { } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; - // A thread link opens the thread here, never a browser. - const threadLink = href ? parseThreadLinkHref(href) : null; - if (threadLink) { - return ( - - {children} - + // A thread link names a thread in this message's environment and opens it in the app. + const linkedThreadId = href ? parseThreadLinkHref(href) : null; + if (linkedThreadId) { + const label = hastPlainTextDeep(node) || linkedThreadId; + return environmentId ? ( + + ) : ( + {label} ); } const contextReference = href ? parseComposerContextHref(href) : null; diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index cc3f5bdb0efb..4b1b44d7c62b 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -1,31 +1,41 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { formatThreadLink, percentDecodedThreadLinkId } from "@t3tools/shared/threadLinks"; import { Link } from "@tanstack/react-router"; import { MessageSquareTextIcon } from "lucide-react"; -import type { ReactNode } from "react"; import { useProject, useThreadShell } from "../../state/entities"; import { ProjectFavicon } from "../ProjectFavicon"; /** - * A `t3-thread://` link in chat. It leads with the thread's project icon, like - * a web link leads with its favicon, so it reads as a thread link before you - * hover it. Opens the thread in the app. + * A `t3-thread://` link in chat. It shows the thread's current title, so a rename + * reaches every message that links to it, and leads with the thread's project icon + * the way a web link leads with its favicon. `label` is what the message wrote, + * shown only when this client cannot see the thread. Opens the thread in the app. */ export function MarkdownThreadLink(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; - readonly children: ReactNode; + readonly label: string; }) { - const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); + const decodedId = percentDecodedThreadLinkId(props.threadId); + const written = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); + const decoded = useThreadShell( + written === null && decodedId !== null ? scopeThreadRef(props.environmentId, decodedId) : null, + ); + const thread = written ?? decoded; + const threadId = thread?.id ?? props.threadId; const project = useProject( thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId), ); + const title = thread?.title.trim() || props.label; return ( )} - {props.children} + {title} ); } diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 08c2f19bdabf..5fc670a99c6d 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -352,9 +352,12 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`. Actor and ingress are separate so agent-authored user-role messages remain distinguishable from human-authored messages. -List, read, and launch results include `link`, a Markdown link of the form -`[title](t3-thread://v1//)` that clients open as the -thread. List and read results also report `snoozed` and `snoozedUntil`, and +Agents mention another thread as `[title](t3-thread://v1/)`. The +link carries only the id, which resolves in the environment of the message that +holds it. Clients show the thread's current title rather than the label, so a +rename never leaves a stale link. + +List and read results report `snoozed` and `snoozedUntil`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread wakes early when it has a pending request, fails, or completes after the snooze. diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d13cf419f19f..0ab3e0b9b86c 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -304,8 +304,6 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, title: Schema.String, createdBy: OrchestrationV2Actor, creationSource: OrchestrationV2CreationSource, @@ -353,8 +351,6 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp export const OrchestratorMcpThreadDetail = Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, projectId: ProjectId, title: Schema.String, createdBy: OrchestrationV2Actor, diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index b6f853e9827f..087aa8894f6a 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -1,28 +1,75 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts"; +import { formatThreadLink, parseThreadLinkHref, relabelThreadLinks } from "./threadLinks.ts"; describe("thread links", () => { - it("round-trips ids that contain URL characters", () => { - const link = formatThreadLink({ - environmentId: "studio mac", - threadId: "mcp:(1)/2", - title: "Fix [the] build\nnow", - }); - const href = /\]\((.+)\)$/.exec(link)![1]!; - expect(link.startsWith("[Fix the build now](")).toBe(true); - // A raw parenthesis would end the Markdown link early. - expect(href).not.toMatch(/[()]/); - expect(parseThreadLinkHref(href)).toEqual({ - environmentId: "studio mac", - threadId: "mcp:(1)/2", - }); + it("takes the thread id verbatim, percent escapes included", () => { + expect(parseThreadLinkHref("t3-thread://v1/mcp:1234")).toBe("mcp:1234"); + expect(parseThreadLinkHref("t3-thread://v1/thread:delegated-task:mcp%3A1")).toBe( + "thread:delegated-task:mcp%3A1", + ); }); - it("rejects other links and malformed ones", () => { + it("rejects other links and an empty id", () => { expect(parseThreadLinkHref("https://t3.codes")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/only-one")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/env/%E0%A4%A")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/%20/thread")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/ ")).toBeNull(); + }); + + it("resolves a percent-encoded id when the id as written names no thread", () => { + const titles = new Map([ + ["thread:project:1", "Decoded"], + ["provider%3A1", "Literal escape"], + ]); + expect( + relabelThreadLinks( + "[a](t3-thread://v1/thread%3Aproject%3A1) [b](t3-thread://v1/provider%3A1)", + (threadId) => titles.get(threadId), + ), + ).toBe( + "[Decoded](t3-thread://v1/thread:project:1) [Literal escape](t3-thread://v1/provider%3A1)", + ); + // A thread with an empty title still exists, so its link is not redirected. + const untitled = new Map([ + ["a%3A1", ""], + ["a:1", "Other"], + ]); + expect( + relabelThreadLinks("[Kept](t3-thread://v1/a%3A1)", (threadId) => untitled.get(threadId)), + ).toBe("[Kept](t3-thread://v1/a%3A1)"); + }); + + it("leaves links inside code spans and fences as written", () => { + const markdown = [ + "Live [old](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.", + "```md", + "[old](t3-thread://v1/t1)", + "```", + "After [old](t3-thread://v1/t1)", + ].join("\n"); + expect(relabelThreadLinks(markdown, () => "New")).toBe( + [ + "Live [New](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.", + "```md", + "[old](t3-thread://v1/t1)", + "```", + "After [New](t3-thread://v1/t1)", + ].join("\n"), + ); + }); + + it("formats a label that would otherwise break the Markdown link", () => { + expect(formatThreadLink("t1", "Fix [ci] \\ build")).toBe("[Fix ci build](t3-thread://v1/t1)"); + expect(formatThreadLink("t1", " ] ")).toBe("[t1](t3-thread://v1/t1)"); + }); + + it("relabels links with the current title and leaves unknown threads alone", () => { + const titles = new Map([["renamed", "Fix [the] build\nnow"]]); + expect( + relabelThreadLinks( + "See [Old name](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted).", + (threadId) => titles.get(threadId), + ), + ).toBe("See [Fix the build now](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted)."); }); }); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index 179350c1ee6b..15384aeea4d4 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -1,58 +1,73 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; /** - * In-app links to threads. Agents put them in Markdown as - * `[title](t3-thread://v1//)`, and clients open the - * thread instead of a browser. Thread tools return a ready `link` for each - * thread, so agents never build one by hand. + * Agents mention another thread in Markdown as `[title](t3-thread://v1/)`. The id is + * the reference and resolves in the environment of the message that holds it. Titles change, so + * clients show the thread's current title; the label only stands in for a thread they cannot see. */ export const THREAD_LINK_PROTOCOL = "t3-thread"; const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; -const LINK_LABEL_MAX_CHARS = 120; +// Code comes first in the alternation so a link written inside a code span or fence is skipped. +const THREAD_LINK_OUTSIDE_CODE = + /(?(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(`+)[^\n]*?\4)|\[[^\]\n]*\]\((?t3-thread:\/\/v1\/[^\s)]+)\)/g; -const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId); const decodeThreadId = Schema.decodeUnknownOption(ThreadId); -// encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early. -function encodeIdSegment(id: string): string { - return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29"); -} - -function formatThreadLinkHref(environmentId: string, threadId: string): string { - return `${THREAD_LINK_HREF_PREFIX}${encodeIdSegment(environmentId)}/${encodeIdSegment(threadId)}`; -} - -/** A Markdown link to the thread, labeled with its title. */ -export function formatThreadLink(thread: { - readonly environmentId: string; - readonly threadId: string; - readonly title: string; -}): string { - const label = - thread.title - .replace(/[[\]\\\r\n]/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, LINK_LABEL_MAX_CHARS) || "Untitled thread"; - return `[${label}](${formatThreadLinkHref(thread.environmentId, thread.threadId)})`; +/** The id as written. Thread ids can hold percent escapes of their own, so it is not decoded. */ +export function parseThreadLinkHref(href: string): ThreadId | null { + if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; + return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length))); } -export function parseThreadLinkHref( - href: string, -): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null { - if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; - const parts = href.slice(THREAD_LINK_HREF_PREFIX.length).split("/"); - if (parts.length !== 2) return null; +/** + * Agents often percent-encode the id anyway. When the id as written names no thread, clients try + * this decoded form. Null when decoding changes nothing or fails. + */ +export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null { try { - const environmentId = decodeEnvironmentId(decodeURIComponent(parts[0]!)); - const threadId = decodeThreadId(decodeURIComponent(parts[1]!)); - return Option.isSome(environmentId) && Option.isSome(threadId) - ? { environmentId: environmentId.value, threadId: threadId.value } - : null; + const decoded = decodeURIComponent(threadId); + return decoded === threadId ? null : Option.getOrNull(decodeThreadId(decoded)); } catch { - // Malformed percent encoding. return null; } } + +/** A thread link whose label survives Markdown: no brackets, backslashes, or line breaks. */ +export function formatThreadLink(threadId: string, label: string): string { + const cleaned = label + .replace(/[[\]\\\r\n]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return `[${cleaned || threadId}](${THREAD_LINK_HREF_PREFIX}${threadId})`; +} + +export function hasThreadLinks(markdown: string): boolean { + return markdown.includes(`](${THREAD_LINK_HREF_PREFIX}`); +} + +/** + * Relabels each thread link with `title(threadId)`, pointing it at the thread that title came from. + * A link it returns nothing for keeps its label. + */ +export function relabelThreadLinks( + markdown: string, + title: (threadId: ThreadId) => string | undefined, +): string { + if (!hasThreadLinks(markdown)) return markdown; + return markdown.replace(THREAD_LINK_OUTSIDE_CODE, (source, ...args) => { + const href = (args.at(-1) as { href?: string }).href; + if (href === undefined) return source; + const written = parseThreadLinkHref(href); + if (written === null) return source; + // The decoded id only stands in when the id as written names no thread. + const decoded = percentDecodedThreadLinkId(written); + const threadId = + title(written) === undefined && decoded !== null && title(decoded) !== undefined + ? decoded + : written; + const label = title(threadId)?.trim(); + return label ? formatThreadLink(threadId, label) : source; + }); +} From 6f83358ae6d85b5dee7d3280e645e0e9a0aeff62 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:18:01 -0700 Subject: [PATCH 006/273] fix(web): messages with quotes or links no longer collapse when short (#16624) --- .../components/chat/AssistantCitationChip.tsx | 8 ++-- .../chat/MessagesTimeline.logic.test.ts | 37 ++++++++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 31 ++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 14 +------ packages/shared/src/assistantCitations.ts | 5 +++ 5 files changed, 78 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index e03872553a88..9f5d3a2e1e55 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -1,5 +1,8 @@ import type { AssistantCitation } from "@t3tools/contracts"; -import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { + assistantCitationLabel, + serializeAssistantCitation, +} from "@t3tools/shared/assistantCitations"; import { Link, useNavigate } from "@tanstack/react-router"; import { PencilIcon, QuoteIcon } from "lucide-react"; import { @@ -94,8 +97,7 @@ export function AssistantCitationChip({ }, } : undefined; - const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); - const label = preview.length > 64 ? `${preview.slice(0, 64)}…` : preview; + const label = assistantCitationLabel(citation); const sourceLinkProps = { to: "/$environmentId/$threadId" as const, params: { environmentId: citation.environmentId, threadId: citation.threadId }, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 98339041f68b..93fc89598be8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -21,12 +21,14 @@ import { import { makeStreamingTimelineFixture } from "../../test-fixtures"; import type { TurnDiffSummary } from "../../types"; import { describe, expect, it } from "vite-plus/test"; -import { MessageId, RunId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, RunId } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, deriveMessagesTimelineRowsWithState, + shouldCollapseUserMessage, liveWorkEntryLabel, normalizeCompactToolLabel, resolveAssistantMessageCopyState, @@ -5162,3 +5164,36 @@ describe("live subagents after their parent turn settles", () => { expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]); }); }); + +describe("shouldCollapseUserMessage", () => { + it("measures a quote chip by its label, not its encoded link", () => { + const quote = "A long assistant paragraph that the user quoted. ".repeat(40); + const citation = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("source"), + text: quote, + comment: "Why does this matter?", + start: 0, + end: quote.length, + prefix: "", + suffix: "", + }); + + expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false); + expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true); + }); + + it("measures file links and context chips by their label", () => { + const links = Array.from( + { length: 8 }, + (_, index) => + `[file${index}.ts](/workspace/projects/example/packages/some/deeply/nested/directory/file${index}.ts)`, + ); + const text = `Compare ${links.join(", ")} with [terminal 1](t3-context://v1/terminal/${"a".repeat(36)}).`; + + expect(text.length).toBeGreaterThan(600); + expect(shouldCollapseUserMessage(text)).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 74f3991396ef..4a81905eaa2b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2,6 +2,10 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setu export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as Equal from "effect/Equal"; +import { + assistantCitationLabel, + collectAssistantCitations, +} from "@t3tools/shared/assistantCitations"; import { shallow } from "zustand/vanilla/shallow"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; import { @@ -2226,3 +2230,30 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean } } } + +const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; +const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; +const MARKDOWN_LINK = /!?\[([^\]\n]*)\]\((?:<[^>\n]*>|[^\s)]*)\)/g; + +function visibleUserMessageText(text: string): string { + const linkLabels = (segment: string) => segment.replace(MARKDOWN_LINK, "$1"); + let visible = ""; + let cursor = 0; + for (const match of collectAssistantCitations(text)) { + visible += linkLabels(text.slice(cursor, match.start)) + assistantCitationLabel(match.citation); + cursor = match.end; + } + return visible + linkLabels(text.slice(cursor)); +} + +export function shouldCollapseUserMessage(text: string): boolean { + const visible = visibleUserMessageText(text); + if (visible.trim().length === 0) { + return false; + } + + return ( + visible.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || + visible.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6145098f6043..8fd9789d9d33 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -207,6 +207,7 @@ import { resolveTimelineMinimapNavigationInteractive, resolveTimelineMinimapTopPercent, resolveWorkGroupScrollIndex, + shouldCollapseUserMessage, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, threadReadLabelPrefix, @@ -4522,22 +4523,9 @@ function UserMessageContextReferenceChip(props: { }); } -const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; -const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; -function shouldCollapseUserMessage(text: string): boolean { - if (text.trim().length === 0) { - return false; - } - - return ( - text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || - text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES - ); -} - const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: { text: string; renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts index e27e35f352bb..86ca0701f20f 100644 --- a/packages/shared/src/assistantCitations.ts +++ b/packages/shared/src/assistantCitations.ts @@ -98,6 +98,11 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu } } +export function assistantCitationLabel(citation: AssistantCitation): string { + const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); + return preview.length > 64 ? `${preview.slice(0, 64)}…` : preview; +} + export function serializeAssistantCitation(citation: AssistantCitation): string { return `[Assistant quote](${formatAssistantCitationHref(citation)})`; } From 843c593ab2dfe432abfba7b928292f3f8e9c7d44 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:18:29 -0700 Subject: [PATCH 007/273] refactor(web): PR loading skeleton shares the detail panel's layout (#15583) --- .../pullRequest/PullRequestDetailLayout.tsx | 130 ++++++ .../pullRequest/PullRequestDetailPanel.tsx | 413 ++++++++---------- .../pullRequest/PullRequestGhosts.tsx | 195 ++++----- 3 files changed, 412 insertions(+), 326 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx new file mode 100644 index 000000000000..92bd0ab324d5 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx @@ -0,0 +1,130 @@ +import { ArrowLeftIcon, FileDiffIcon } from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; + +import { cn } from "~/lib/utils"; + +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { PullRequestMetaLine } from "./pullRequestPresentation"; + +export function PullRequestDetailHeaderBody({ + title, + author, + updated, + checkout, + base, + head, + files, + diffStat, +}: { + title: ReactNode; + author: ReactNode; + updated: ReactNode; + checkout?: ReactNode; + base: ReactNode; + head: ReactNode; + files: ReactNode; + diffStat: ReactNode; +}) { + return ( +
+ {title} +
+ + {author} + {updated} + + {checkout} +
+
+ + {base} + + {head} + + + + + {files} + + {diffStat} + +
+
+ ); +} + +export function PullRequestDetailTitleRow({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +export function PullRequestDetailTabBar({ + tabs, + value, + onValueChange, + onTabIntent, + inert, + children, +}: { + tabs: ReadonlyArray<{ readonly value: Tab; readonly label: string }>; + value: Tab; + onValueChange?: (tab: Tab) => void; + onTabIntent?: (tab: Tab) => void; + inert?: boolean; + children?: ReactNode; +}) { + return ( + + ); +} + +export function PullRequestChecksStatusLine({ + icon, + label, + className, + ...props +}: Omit, "children"> & { icon: ReactNode; label: ReactNode }) { + return ( + + {icon} + {label} + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index f44896073985..7f6a4592a0b4 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -102,7 +102,6 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { PullRequestEditButton } from "./PullRequestEditButton"; import { Input } from "../ui/input"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Menu, MenuItem, @@ -117,6 +116,12 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { MiddleTruncate } from "../ui/middle-truncate"; +import { + PullRequestChecksStatusLine, + PullRequestDetailHeaderBody, + PullRequestDetailTabBar, + PullRequestDetailTitleRow, +} from "./PullRequestDetailLayout"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; @@ -2373,77 +2378,79 @@ export function PullRequestDetailPanel({ inert={condensed} > {detail ? ( -
- {titleDraft === null ? ( -
- - - {detail.title} - - } - /> - {detail.title} - - {canEditPullRequestChangeRequest(detail) ? ( - setTitleScope({ pullRequestKey, text: detail.title })} - /> - ) : null} -
- ) : ( - // A title is one line of text, not markdown, so it takes an input rather than - // the editor the description and the remarks share. -
- - setTitleScope({ pullRequestKey, text: event.target.value }) - } - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void saveTitle(titleDraft); - } else if (event.key === "Escape") { - event.preventDefault(); - setTitleScope(null); - } - }} - /> -
- - + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void saveTitle(titleDraft); + } else if (event.key === "Escape") { + event.preventDefault(); + setTitleScope(null); + } + }} + /> +
+ + +
-
- )} -
- - - updated {formatRelativeTimeLabel(detail.updatedAt)} - - {checkoutCommand ? ( + ) + } + author={ + + } + updated={updated {formatRelativeTimeLabel(detail.updatedAt)}} + checkout={ + checkoutCommand ? ( - ) : null} -
- -
- - {/* An out-of-date base wears the warning on the branch name itself, so the + ) : null + } + base={ + /* An out-of-date base wears the warning on the branch name itself, so the name is amber and pointing at either the name or the mark opens the way - out. Up to date, the name keeps its plain tooltip. */} - {freshness ? ( - void perform("update-branch", undefined, method)} - className="max-w-[40%]" - > - {isStackedPullRequest ? ( - - ) : null} - - - - - ) : ( - - - {isStackedPullRequest ? ( - - ) : null} - - - - - } + out. Up to date, the name keeps its plain tooltip. */ + freshness ? ( + void perform("update-branch", undefined, method)} + className="max-w-[40%]" + > + {isStackedPullRequest ? ( + - - {isStackedPullRequest - ? `Stacked on ${detail.baseBranch}` - : detail.baseBranch} - - - )} - - - - - - - {detail.changedFiles.toLocaleString()}{" "} - {detail.changedFiles === 1 ? "file" : "files"} - - - -
-
+ ) : null} + + + + + ) : ( + + + {isStackedPullRequest ? ( + + ) : null} + + + + + } + /> + + {isStackedPullRequest + ? `Stacked on ${detail.baseBranch}` + : detail.baseBranch} + + + ) + } + head={ + + } + files={`${detail.changedFiles.toLocaleString()} ${detail.changedFiles === 1 ? "file" : "files"}`} + diffStat={ + + } + /> ) : null}
{detail ? ( - + ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 85c9802c2170..7e155882a34a 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -13,7 +13,6 @@ import { ChevronRightIcon, EllipsisIcon, ExternalLinkIcon, - FileDiffIcon, PanelRightIcon, TagIcon, UserPlusIcon, @@ -26,20 +25,24 @@ import { cn } from "~/lib/utils"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button, InlineButton } from "../ui/button"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { MiddleTruncate } from "../ui/middle-truncate"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; +import { + PullRequestChecksStatusLine, + PullRequestDetailHeaderBody, + PullRequestDetailTabBar, + PullRequestDetailTitleRow, +} from "./PullRequestDetailLayout"; import { PullRequestActorLabel, PullRequestDiffStat, PullRequestLabelChip, - PullRequestMetaLine, pullRequestChecksStatePresentation, resolvePullRequestState, } from "./pullRequestPresentation"; function GhostBar({ className }: { className?: string | undefined }) { - return
; + return ; } /** Widths cycle rather than randomize, so the ghost renders the same on every pass. */ @@ -202,32 +205,35 @@ export function PullRequestDetailGhost({
-
- {seed ? ( -
-

{seed.title}

-
- ) : ( -
- -
- )} -
- {seed ? ( - - - updated {formatRelativeTimeLabel(seed.updatedAt)} - + + {seed ? ( +

{seed.title}

+ ) : ( + + )} + + } + author={ + seed ? ( + ) : ( - - - - - - - - )} - {checkout ? ( + + + + + ) + } + updated={ + seed ? ( + updated {formatRelativeTimeLabel(seed.updatedAt)} + ) : ( + + ) + } + checkout={ + checkout ? ( - ) : null} -
- -
- - {seed ? ( - - - - - - ) : ( - - - - )} - - {seed ? ( - - ) : ( - - )} - - - - - {changedFiles === null ? ( - - ) : ( - `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}` - )} - + ) : null + } + base={ + {seed ? ( - + + + ) : ( - + )} -
-
+ } + head={ + seed ? ( + + ) : ( + + ) + } + files={ + changedFiles === null ? ( + + ) : ( + `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}` + ) + } + diffStat={ + seed ? ( + + ) : ( + + ) + } + />
- +
From 2b7fa212a89edda7aad8f73fc3b4afd5b56c442c Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:20:36 -0700 Subject: [PATCH 008/273] fix(web): PR file headers keep the file name in narrow panels (#15562) --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 9af92ee96a81..caadd569b9c5 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -858,7 +858,7 @@ function PullRequestCodeTab({ ) : ( - "Viewed" + Viewed )} @@ -1503,7 +1503,7 @@ function PullRequestCodeTab({ // diff crosses the overflow boundary. The viewer is itself focusable for keyboard // interaction, but its native host outline clips and competes with the focus // indicators on its actual controls. - className="h-full overflow-auto [scrollbar-gutter:stable]" + className="@container h-full overflow-auto [scrollbar-gutter:stable]" viewerRef={setViewer} items={items} selectedLines={selectedLines} From b24f0fbba09d8623c896081b4ff70aa7190c8fda Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:22:25 -0700 Subject: [PATCH 009/273] fix(web): PR timeline no longer shifts when its scrollbar appears (#15588) --- apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 4b3997bf5f62..c6174b54f135 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -577,7 +577,7 @@ export function PullRequestTimelineTab({ }; return ( -
+
From 0bfd9dd19d0c9418a936190525ec8f4c4fdae09b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 21:49:54 -0700 Subject: [PATCH 010/273] fix(server): held queued wakes no longer keep delegated tasks running (#17028) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/OrchestratorMcpService.ts | 2 + ...OrchestratorMcpToolkit.integration.test.ts | 14 ++ .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../DelegatedCompletionDelivery.test.ts | 192 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 32 +-- .../ProjectionRecovery.test.ts | 19 +- .../src/orchestration-v2/ProjectionStore.ts | 8 +- .../SubagentProjection.test.ts | 8 + .../orchestration-v2/SubagentProjection.ts | 6 +- 9 files changed, 265 insertions(+), 18 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index ebb1aa9e4c1a..78d4ee48e666 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -370,9 +370,11 @@ export function hasPendingChildRuns( childProjection: Pick, delegatedRun: OrchestrationV2Run | undefined, ): boolean { + // Held queued runs wait for the user to resume the child; task_cancel holds them. return childProjection.runs.some( (run) => !ThreadManagementService.isTerminalRunStatus(run.status) && + !(run.status === "queued" && run.queueHeld === true) && (delegatedRun === undefined || run.ordinal > delegatedRun.ordinal), ); } diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index e59f8fff9a05..0dbb2f571683 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1873,6 +1873,20 @@ describe("orchestrator MCP toolkit", () => { legacyDelegatedRun, ), ).toBe(true); + // A queue Stop held waits for the user; it is not pending child work. + expect( + hasPendingChildRuns( + { + ...legacyChildProjection, + runs: legacyChildProjection.runs.map((run) => + run.id === activeChildFollowup.runId + ? { ...run, status: "queued", queueHeld: true } + : run, + ), + }, + legacyDelegatedRun, + ), + ).toBe(false); const completedTaskCancelCall = yield* invoke("task_cancel", { taskId: delegated.taskId, reason: "Stop the child's later work too.", diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index e7ef07101a39..ac0ebb63b7dd 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -76,7 +76,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", { const TaskStatusTool = Tool.make("task_status", { description: - "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task, and task_cancel stops those turns too. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", + "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task, and task_cancel stops those turns too. Turns held in a stopped queue wait for the user and do not count. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", parameters: OrchestratorMcpTaskStatusInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 6f1819a672b7..0344e3aed4cf 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -14,6 +14,7 @@ import { ProviderThreadId, RunId, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -1204,6 +1205,197 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { }), ); + it.effect("settles a cancelled child whose held wakes wait behind it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:held-wake-parent"); + const projectId = ProjectId.make("project:held-wake-parent"); + const runId = RunId.make("run:held-wake-parent"); + const rootNodeId = NodeId.make("node:held-wake-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:held-wake-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "held-wake-child", + completionWake: "always", + continuationPending: false, + now, + }); + // Pull request watch wakes queued while the child ran; the restart held them. + const held = runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:held-wake-child:2"), + ordinal: 2, + status: "queued", + now, + }); + yield* eventSink.write({ + commandId: CommandId.make("command:held-wake-child:held"), + events: [ + { + ...held, + payload: { ...held.payload, startedAt: null, completedAt: null, queueHeld: true }, + }, + ], + }); + + yield* orchestrator.recoverDelegatedTasks; + + const recovered = yield* orchestrator.getThreadProjection(threadId); + const task = recovered.subagents.find((row) => row.id === child.taskId); + assert.equal(task?.status, "cancelled"); + assert.isNotNull(task?.result ?? null); + assert.isTrue( + recovered.contextTransfers.some( + (transfer) => + transfer.type === "subagent_result" && transfer.sourceThreadId === child.childThreadId, + ), + ); + assert.isFalse(yield* orchestrator.delegatedTaskResultPending(child.childThreadId)); + // The held wake stays held for the user to resume or discard. + const childProjection = yield* orchestrator.getThreadProjection(child.childThreadId); + assert.deepEqual( + childProjection.runs.map((run) => [run.status, run.queueHeld ?? false]), + [ + ["cancelled", false], + ["queued", true], + ], + ); + }), + ); + + it.effect("settles a child whose provider failure holds its queued wakes", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:failed-hold-parent"); + const projectId = ProjectId.make("project:failed-hold-parent"); + const runId = RunId.make("run:failed-hold-parent"); + const rootNodeId = NodeId.make("node:failed-hold-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:failed-hold-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "failed-hold-child", + completionWake: "always", + continuationPending: false, + now, + }); + // The child is resumed, and a wake queues behind its running turn. + const activeRun = runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:failed-hold-child:2"), + ordinal: 2, + status: "running", + now, + }); + const queued = runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:failed-hold-child:3"), + ordinal: 3, + status: "queued", + now, + }); + yield* eventSink.write({ + commandId: CommandId.make("command:failed-hold-child:resumed"), + events: [ + activeRun, + { ...queued, payload: { ...queued.payload, startedAt: null, completedAt: null } }, + ], + }); + const afterSequence = yield* eventSink.latestSequence(); + const errorItemId = TurnItemId.make("turn-item:failed-hold-child:error"); + yield* eventSink.write({ + commandId: CommandId.make("command:failed-hold-child:failed"), + events: [ + { + id: EventId.make("event:failed-hold-child:error"), + type: "turn-item.updated", + threadId: child.childThreadId, + runId: activeRun.runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: errorItemId, + type: "error", + threadId: child.childThreadId, + runId: activeRun.runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "failed", + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "provider_error", + message: "Provider failed.", + code: "provider_failed", + retryable: null, + }, + }, + }, + { + ...activeRun, + id: EventId.make("event:failed-hold-child:failed"), + payload: { ...activeRun.payload, status: "failed", completedAt: now }, + }, + ], + }); + + // The failure holds the queue, which leaves the failure as the task's result. + const settled = yield* eventSink + .stream({ afterSequence, eventType: "subagent.updated" }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && stored.event.payload.id === child.taskId, + ), + Stream.take(1), + Stream.runHead, + ); + assert.isTrue(settled._tag === "Some"); + const recovered = yield* orchestrator.getThreadProjection(threadId); + assert.equal(recovered.subagents.find((row) => row.id === child.taskId)?.status, "failed"); + const childProjection = yield* orchestrator.getThreadProjection(child.childThreadId); + assert.deepEqual( + childProjection.runs.map((run) => [run.status, run.queueHeld ?? false]), + [ + ["cancelled", false], + ["failed", false], + ["queued", true], + ], + ); + }), + ); + it.effect("wakes the parent for a settled_only task once its spawning turn ended", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5d4b1cfda15f..e0d288282b1a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -10613,25 +10613,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // while holding the parent lock, so nesting the parent lock inside the // child lock here would invert that order, and the keyed executor's // semaphores are neither reentrant nor deadlock-aware. - const parentThreadId = yield* appOwnedSubagentParentThreadId(threadId); - if (parentThreadId !== undefined) { - yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); - } if (stored.event.type === "run.updated") { yield* threadDispatch.withLock( threadId, finalizeDelegatedCompletionDelivery(threadId, stored.event.payload.id), ); } - yield* threadDispatch.withLock( - threadId, - startNextQueuedRun( + yield* threadDispatch + .withLock( threadId, - stored.event.type === "run.updated" && stored.event.payload.status === "failed" - ? { failedRunId: stored.event.payload.id } - : undefined, - ), - ); + startNextQueuedRun( + threadId, + stored.event.type === "run.updated" && stored.event.payload.status === "failed" + ? { failedRunId: stored.event.payload.id } + : undefined, + ), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to start the next queued V2 run", { threadId, cause }), + ), + ); + // After the queue decision: a provider failure holds the child's queued + // wakes, and only then is the failed run the task's result. + const parentThreadId = yield* appOwnedSubagentParentThreadId(threadId); + if (parentThreadId !== undefined) { + yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); + } }).pipe( Effect.catchCause((cause) => Effect.logWarning("Failed to react to terminal V2 run", { diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 6bde9f596e2e..c7ea0530d351 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -231,7 +231,7 @@ it.effect("recovers terminal subagent results until their cross-thread transfer const now = yield* DateTime.now; const parent = yield* createThread("subagent-parent"); const children: Array = []; - for (const name of ["terminal", "archived", "deleted", "running"]) { + for (const name of ["terminal", "archived", "deleted", "running", "held", "queued"]) { const child = yield* createThread(`subagent-${name}`, { lineage: { parentThreadId: parent, relationshipToParent: "subagent", rootThreadId: parent }, forkedFrom: { type: "node", nodeId: NodeId.make(`node:${name}`) }, @@ -239,12 +239,22 @@ it.effect("recovers terminal subagent results until their cross-thread transfer deletedAt: name === "deleted" ? now : null, }); yield* createRun(child, name === "running" ? "running" : "completed"); + // A wake queued behind the result: held by Stop or a restart, or still deliverable. + if (name === "held" || name === "queued") { + yield* createRun(child, "queued", { + ordinal: 2, + startedAt: null, + ...(name === "held" ? { queueHeld: true } : {}), + }); + } children.push(child); } + const terminalChildren = new Set([children[0]!, children[1]!, children[4]!]); assert.deepEqual( new Set(yield* projections.getRecoveryThreadIds("subagent-results")), - new Set(children.slice(0, 2)), + terminalChildren, ); + const completed = children[0]!; const transferId = ContextTransferId.make("transfer:recovery:subagent-result"); yield* projections.apply({ @@ -273,7 +283,10 @@ it.effect("recovers terminal subagent results until their cross-thread transfer }); const archivedChild = children[1]; assert.isDefined(archivedChild); - assert.deepEqual(yield* projections.getRecoveryThreadIds("subagent-results"), [archivedChild]); + assert.deepEqual( + new Set(yield* projections.getRecoveryThreadIds("subagent-results")), + new Set([archivedChild, children[4]!]), + ); assert.deepEqual(yield* projections.getUnreadableThreadIds(), []); yield* sql` UPDATE orchestration_v2_projection_context_transfers SET payload_json = '{}' diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4da19774075b..11f98f2330b5 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -514,7 +514,7 @@ function needsRecovery( parentThreadId !== null && projection.thread.forkedFrom?.type === "node" && ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( - projection.runs.at(-1)?.status ?? "idle", + latestUnheldRun(projection.runs)?.status ?? "idle", ) && !projection.contextTransfers.some( (transfer) => @@ -3505,9 +3505,15 @@ export const layer: Layer.Layer = json_extract(child.payload_json, '$.lineage.relationshipToParent') = 'subagent' AND json_extract(child.payload_json, '$.lineage.parentThreadId') IS NOT NULL AND json_extract(child.payload_json, '$.forkedFrom.type') = 'node' + -- A held queue waits for the user, so the newest unheld run + -- decides, matching latestUnheldRun. AND ( SELECT status FROM orchestration_v2_projection_runs WHERE thread_id = child.thread_id + AND NOT ( + status = 'queued' + AND json_extract(payload_json, '$.queueHeld') IS 1 + ) ORDER BY ordinal DESC LIMIT 1 ) IN ('completed', 'interrupted', 'failed', 'cancelled', 'rolled_back') AND NOT EXISTS ( diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index 23d180102cd3..e2691e08e0db 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -237,6 +237,14 @@ it("waits for nested work and retains the report across monitor acknowledgements startedAt: null, }; assert.equal(delegatedTaskProgress({ ...projection, runs: [run, pending] }).state, "working"); + // Stop or a restart holds queued wakes for the user; they are not work the task owes. + const cancelled = { ...run, status: "cancelled" as const }; + const held = delegatedTaskProgress({ + ...projection, + runs: [cancelled, { ...pending, queueHeld: true }], + }); + assert.equal(held.state, "result_available"); + assert.equal(held.resultRun?.id, cancelled.id); const report = { ...pending, status: "completed" as const, startedAt: parentCreatedAt }; const monitor = { ...report, id: RunId.make("monitor"), ordinal: 3 }; const artifacts = makeSubagentConversationArtifacts({ diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index f28dc1aa4be5..7ee8d7c1aa8d 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -229,7 +229,11 @@ export function delegatedTaskProgress(projection: { const workRuns = projection.runs.filter( (run) => !monitorRuns.has(run.id) && run.status !== "rolled_back", ); - const active = workRuns.some((run) => !terminal(run.status)); + // A held queue waits for the user to resume it (after Stop, a restart, or a + // provider failure), so its runs are not work the task still owes. + const active = workRuns.some( + (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true), + ); const children = projection.subagents.some( (task) => From c28cd905572e2a28934a6229d88bcf2975963032 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 7 Oct 2026 22:20:16 -0700 Subject: [PATCH 011/273] fix(server): a message sent during a rollback no longer undoes it (#17079) Co-authored-by: Claude Opus 5.5 (1M context) --- .../CheckpointRestoreSafety.test.ts | 2 + .../CheckpointRollbackService.test.ts | 21 ++- .../CheckpointRollbackService.ts | 131 ++++++++++-------- .../src/orchestration-v2/runtimeLayer.ts | 1 + .../testkit/ProviderReplayHarness.ts | 1 + 5 files changed, 95 insertions(+), 61 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index 5f79aefdd0b7..34bf5cce6a53 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { EventSinkV2 } from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; @@ -102,6 +103,7 @@ it.effect.each([ Layer.provide( Layer.mergeAll( NodeServices.layer, + ThreadCommandExecutor.layer, IdAllocator.layer, Layer.mock(ProjectStore.ProjectStoreV2)({ get: () => Effect.succeed(Option.some({ workspaceRoot: parent } as never)), diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index 433a3be43ac3..bb37290363ac 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -20,6 +20,8 @@ import { resolveCodexRollbackTurnCount } from "./Adapters/CodexAdapterV2.ts"; import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -64,6 +66,7 @@ it.effect("rejects a non-ready checkpoint before opening a session or restoring const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -141,6 +144,7 @@ it.effect("rejects a rollback when another provider thread became active", () => const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -221,6 +225,7 @@ it.effect("rejects a rollback when provider selection changed before execution", const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -292,6 +297,7 @@ it.effect("reports a missing provider turn as a structured rollback failure", () const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -352,6 +358,15 @@ it.effect.each([ const checkpointId = CheckpointId.make("rewind-start"); const scopeId = CheckpointScopeId.make("rewind-scope"); const calls: string[] = []; + let lockedThreads = Effect.succeed>([]); + const layerThreadCommands = Layer.effect( + ThreadCommandExecutor.ThreadCommandExecutor, + Effect.tap(KeyedLock.make(), (lock) => + Effect.sync(() => { + lockedThreads = lock.activeKeys; + }), + ), + ); const providerThread = { id: providerThreadId, providerSessionId, @@ -390,6 +405,7 @@ it.effect.each([ const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + layerThreadCommands, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore: () => Effect.sync(() => { @@ -398,7 +414,10 @@ it.effect.each([ }), Layer.mock(EventSink.EventSinkV2)({ write: ({ events }) => - Effect.sync(() => { + Effect.map(lockedThreads, (locked) => { + // A thread command that planned against the old provider thread + // must not commit after this and undo the rollback. + assert.include(locked, threadId); assert.ok( events.some( (event) => event.type === "run.updated" && event.payload.status === "rolled_back", diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 1710e7a8cd84..b1532a74c6dc 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -26,6 +26,7 @@ import * as ProjectStore from "./ProjectStore.ts"; import type { ProviderAdapterV2RollbackTarget } from "./ProviderAdapter.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; export const ROLLBACK_FAILED_MESSAGE = "The provider could not roll back this conversation. Try again; if it keeps failing, check the provider and server logs."; @@ -91,6 +92,7 @@ export const layer: Layer.Layer< | FileSystem.FileSystem | Path.Path | ProjectStore.ProjectStoreV2 + | ThreadCommandExecutor.ThreadCommandExecutor > = Layer.effect( CheckpointRollbackServiceV2, Effect.gen(function* () { @@ -103,6 +105,7 @@ export const layer: Layer.Layer< const fileSystem = yield* FileSystem.FileSystem; const projects = yield* ProjectStore.ProjectStoreV2; const path = yield* Path.Path; + const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const execute = Effect.fn("orchestrationV2.checkpointRollback.execute")(function* (input: { readonly threadId: ThreadId; @@ -274,72 +277,80 @@ export const layer: Layer.Layer< yield* checkpoints.deleteStaleRefs({ scope, checkpoints: staleCheckpoints }); } - const now = yield* DateTime.now; - const makeEvent = (event: Omit) => - Effect.map( - ids.allocate.event({ threadId: event.threadId }), - (id) => - ({ - ...event, - id, - }) as Event, - ); - const events: Array = []; - events.push( - yield* makeEvent({ - type: "provider-thread.updated", - threadId: input.threadId, - driver: providerThread.driver, - providerInstanceId: providerThread.providerInstanceId, - occurredAt: now, - payload: { - ...snapshot.providerThread, - lastRunOrdinal: targetOrdinal === 0 ? null : targetOrdinal, - updatedAt: now, - }, - }), - ); - for (const staleCheckpoint of staleCheckpoints) { - events.push( - yield* makeEvent({ - type: "checkpoint.captured", - threadId: input.threadId, - ...(staleCheckpoint.runId === null ? {} : { runId: staleCheckpoint.runId }), - nodeId: staleCheckpoint.nodeId, - providerInstanceId: providerThread.providerInstanceId, - occurredAt: now, - payload: { ...staleCheckpoint, status: "stale" }, - }), - ); - } - for (const run of runsToRollback) { - const rootNode = projection.nodes.find((candidate) => candidate.id === run.rootNodeId); - events.push( - yield* makeEvent({ - type: "run.updated", - threadId: input.threadId, - runId: run.id, - ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: { ...run, status: "rolled_back", completedAt: now }, - }), - ); - if (rootNode !== undefined) { + // Thread commands write full provider thread rows under this lock. Without + // it, a message sent during the rollback can plan against the old native + // session and commit after this, undoing the provider's fork. + yield* threadCommands.withLock( + input.threadId, + Effect.gen(function* () { + const now = yield* DateTime.now; + const makeEvent = (event: Omit) => + Effect.map( + ids.allocate.event({ threadId: event.threadId }), + (id) => + ({ + ...event, + id, + }) as Event, + ); + const events: Array = []; events.push( yield* makeEvent({ - type: "node.updated", + type: "provider-thread.updated", threadId: input.threadId, - runId: run.id, - nodeId: rootNode.id, - providerInstanceId: run.providerInstanceId, + driver: providerThread.driver, + providerInstanceId: providerThread.providerInstanceId, occurredAt: now, - payload: { ...rootNode, status: "rolled_back", completedAt: now }, + payload: { + ...snapshot.providerThread, + lastRunOrdinal: targetOrdinal === 0 ? null : targetOrdinal, + updatedAt: now, + }, }), ); - } - } - yield* eventSink.write({ events }); + for (const staleCheckpoint of staleCheckpoints) { + events.push( + yield* makeEvent({ + type: "checkpoint.captured", + threadId: input.threadId, + ...(staleCheckpoint.runId === null ? {} : { runId: staleCheckpoint.runId }), + nodeId: staleCheckpoint.nodeId, + providerInstanceId: providerThread.providerInstanceId, + occurredAt: now, + payload: { ...staleCheckpoint, status: "stale" }, + }), + ); + } + for (const run of runsToRollback) { + const rootNode = projection.nodes.find((candidate) => candidate.id === run.rootNodeId); + events.push( + yield* makeEvent({ + type: "run.updated", + threadId: input.threadId, + runId: run.id, + ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, status: "rolled_back", completedAt: now }, + }), + ); + if (rootNode !== undefined) { + events.push( + yield* makeEvent({ + type: "node.updated", + threadId: input.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "rolled_back", completedAt: now }, + }), + ); + } + } + yield* eventSink.write({ events }); + }), + ); }); return CheckpointRollbackServiceV2.of({ diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 98e05de9a5a2..9cf3351f5d25 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -182,6 +182,7 @@ const layerCheckpointRollbackServiceProvided = CheckpointRollbackService.layer.p IdAllocator.layer, ProjectionStore.layer, layerProviderSessionManagerProvided, + ThreadCommandExecutor.layer, layerRuntimePolicyProvided, ), ), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 83762ef06cea..230ad7aa0feb 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -395,6 +395,7 @@ export function layerWithRegistry( IdAllocator.layer, layerStores, layerProviderSessionManagerProvided, + ThreadCommandExecutor.layer, layerRuntime, ), ), From 6053a7878d24e5fd596d2b417ed94964a4b9c1c6 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:24:58 -0700 Subject: [PATCH 012/273] fix(web): PR file stats ignore the hide-whitespace toggle (#16162) --- .../pullRequest/PullRequestCodeTab.tsx | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index caadd569b9c5..d531259e5781 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -37,6 +37,7 @@ import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, fnv1a32, + getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, @@ -596,6 +597,23 @@ function PullRequestCodeTab({ ), [loadedSlices], ); + const sourceFileStats = useMemo( + () => + new Map( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" + ? parsed.files.map( + (file, index) => + [ + buildFileDiffRenderKey(file), + getDiffLineStat([parsed.sourceFiles[index] ?? file]), + ] as const, + ) + : [], + ), + ), + [parsedSlices], + ); const fileKeys = useMemo(() => items.map((item) => item.id), [items]); const collapsedFileKeys = useMemo( () => new Set(items.filter((item) => item.collapsed === true).map((item) => item.id)), @@ -811,12 +829,8 @@ function PullRequestCodeTab({ const renderHeaderMetadata = useCallback( (item: CodeViewItem) => { if (item.type !== "diff") return null; - let additions = 0; - let deletions = 0; - for (const hunk of item.fileDiff.hunks) { - additions += hunk.additionLines; - deletions += hunk.deletionLines; - } + let { additions, deletions } = + sourceFileStats.get(item.id) ?? getDiffLineStat([item.fileDiff]); const path = resolveFileDiffPath(item.fileDiff); if (additions === 0 && deletions === 0) { const withheld = omittedFileStats.get(path); @@ -864,7 +878,7 @@ function PullRequestCodeTab({ ); }, - [omittedFileStats], + [omittedFileStats, sourceFileStats], ); const diffViewOptions = useMemo( From 28f5516a9acfcfc1e203487e78869a406fd23b76 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:30:47 -0700 Subject: [PATCH 013/273] perf(mobile): omit duplicated turn items from bounded thread snapshots (#15385) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/orchestration-v2/ThreadStream.test.ts | 103 +++++ .../src/orchestration-v2/ThreadStream.ts | 40 +- .../boundedSnapshotTransport.test.ts | 370 ++++++++++++++++++ apps/server/src/orchestration-v2/http.ts | 10 +- apps/server/src/ws.ts | 6 +- .../state/boundedThreadSnapshotHttp.test.ts | 77 ++++ .../src/state/boundedThreadSnapshotHttp.ts | 16 +- .../src/state/threads-sync.test.ts | 111 ++++++ packages/client-runtime/src/state/threads.ts | 7 +- packages/contracts/src/environmentHttp.ts | 9 + .../contracts/src/orchestrationV2.test.ts | 83 ++++ packages/contracts/src/orchestrationV2.ts | 13 + packages/shared/package.json | 4 + .../orchestrationV2BoundedSnapshot.test.ts | 147 +++++++ .../src/orchestrationV2BoundedSnapshot.ts | 58 +++ 15 files changed, 1038 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts create mode 100644 packages/shared/src/orchestrationV2BoundedSnapshot.test.ts create mode 100644 packages/shared/src/orchestrationV2BoundedSnapshot.ts diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index 5ef09f776da0..d64de56cbf8a 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -1,5 +1,6 @@ import { EventId, + OrchestrationV2ThreadStreamItem, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, @@ -8,6 +9,8 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import { buildBoundedThreadStreamSnapshot, @@ -262,3 +265,103 @@ describe("decideThreadResume", () => { ).toEqual({ mode: "replay", afterSequence: 9, throughSequence: 10 }); }); }); + +describe("compact bounded snapshots", () => { + const FORK_PARENT = ThreadId.make("thread-stream-parent"); + const decodeStreamItem = Schema.decodeUnknownSync( + Schema.toCodecJson(OrchestrationV2ThreadStreamItem), + ); + const encodeStreamItem = Schema.encodeSync(Schema.toCodecJson(OrchestrationV2ThreadStreamItem)); + + /** Fork child: inherited parent rows and marker, then local rows, plus an older interrupt request. */ + function forkProjection(): OrchestrationV2ThreadProjection { + const base = timelineProjection(6); + const local = base.visibleTurnItems.slice(3).map((row, index) => ({ ...row, position: index })); + const inherited = base.visibleTurnItems.slice(0, 3).map((row, index) => { + const item = { ...row.item, id: TurnItemId.make(`parent-${index}`), threadId: FORK_PARENT }; + return { + position: index, + visibility: "inherited" as const, + sourceThreadId: FORK_PARENT, + sourceItemId: item.id, + item, + }; + }); + const request = { + ...base.turnItems[0]!, + id: TurnItemId.make("interrupt-request"), + type: "run_interrupt_request", + ordinal: 0, + message: "Stop", + } as unknown as OrchestrationV2TurnItem; + return { + ...base, + turnItems: [request, ...local.map((row) => row.item)], + visibleTurnItems: [...inherited, ...local].map((row, position) => ({ ...row, position })), + }; + } + + it.each([ + ["linear", () => timelineProjection(80)], + ["fork with retained interrupt request", forkProjection], + ])("restores the exact opted-out snapshot for %s threads", (_, makeProjection) => { + const base = makeProjection(); + // The shared fixture omits a model; the wire codec requires one. + const projection = { + ...base, + thread: { ...base.thread, modelSelection: { instanceId: "codex", model: "gpt-5" } }, + } as OrchestrationV2ThreadProjection; + const full = buildBoundedThreadStreamSnapshot({ snapshotSequence: 44, projection }); + const compact = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 44, + projection, + compactTurnItems: true, + }); + + // Opted-out clients get exactly the previous representation. + expect("turnItemsOmitLocalVisible" in full).toBe(false); + expect(compact.turnItemsOmitLocalVisible).toBe(true); + expect(compact.projection.turnItems.length).toBeLessThan(full.projection.turnItems.length); + const { turnItemsOmitLocalVisible: _marker, ...compactFields } = compact; + expect({ ...compactFields, projection: full.projection }).toEqual(full); + + // Decode the wire form like a client, then restore before use. + const decodedFull = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(full)))); + const decodedCompact = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(compact)))); + if (decodedFull.kind !== "snapshot" || decodedCompact.kind !== "snapshot") { + throw new Error("Expected snapshots"); + } + expect(boundedSnapshotProjection(decodedCompact)).toEqual(decodedFull.projection); + }); + + it("keeps the retained interrupt request outside the visible window", () => { + const compact = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 1, + projection: forkProjection(), + compactTurnItems: true, + }); + expect(compact.projection.turnItems.map((item) => item.id)).toEqual(["interrupt-request"]); + expect(boundedSnapshotProjection(compact).turnItems.map((item) => String(item.id))).toEqual([ + "item-3", + "item-4", + "item-5", + "interrupt-request", + ]); + }); + + it("omits the marker when the window has no local rows", () => { + const fork = forkProjection(); + const inheritedOnly = { + ...fork, + turnItems: fork.turnItems.slice(0, 1), + visibleTurnItems: fork.visibleTurnItems.slice(0, 3), + }; + const compact = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 1, + projection: inheritedOnly, + compactTurnItems: true, + }); + expect("turnItemsOmitLocalVisible" in compact).toBe(false); + expect(compact.projection.turnItems.map((item) => item.id)).toEqual(["interrupt-request"]); + }); +}); diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index ca8e2170c06d..fad28abf677a 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -3,7 +3,12 @@ import type { OrchestrationV2ThreadStreamItem, } from "@t3tools/contracts"; -import { buildBoundedThreadProjection } from "./threadHistoryPaging.ts"; +import { omitLocalVisibleTurnItems } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; + +import { + buildBoundedThreadProjection, + type BoundedProjectionResult, +} from "./threadHistoryPaging.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; /** Maximum number of reducer applications allowed during a thread resume. */ @@ -39,10 +44,34 @@ type ThreadSnapshotStreamItem = Extract< { readonly kind: "snapshot" } >; +/** + * Bounded snapshot fields shared by the HTTP route and socket fallbacks. Only + * clients that opted in get compact `turnItems` and the marker; everyone else + * gets the unchanged representation. + */ +export function boundedSnapshotResponseFields(input: { + readonly bounded: BoundedProjectionResult; + readonly compactTurnItems: boolean; +}) { + const { bounded } = input; + const compactProjection = input.compactTurnItems + ? omitLocalVisibleTurnItems(bounded.projection) + : null; + return { + projection: compactProjection ?? bounded.projection, + historyCursor: bounded.historyCursor, + hasMoreHistory: bounded.hasMoreHistory, + latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, + payloadBudgetExceeded: bounded.payloadBudgetExceeded, + ...(compactProjection === null ? {} : { turnItemsOmitLocalVisible: true as const }), + }; +} + /** Build the same bounded, wire-projected snapshot for every socket fallback path. */ export function buildBoundedThreadStreamSnapshot(input: { readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; + readonly compactTurnItems?: boolean; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, @@ -51,11 +80,10 @@ export function buildBoundedThreadStreamSnapshot(input: { return { kind: "snapshot", snapshotSequence: input.snapshotSequence, - projection: bounded.projection, - historyCursor: bounded.historyCursor, - hasMoreHistory: bounded.hasMoreHistory, - latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, - payloadBudgetExceeded: bounded.payloadBudgetExceeded, + ...boundedSnapshotResponseFields({ + bounded, + compactTurnItems: input.compactTurnItems === true, + }), }; } diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts new file mode 100644 index 000000000000..a86b19be95f7 --- /dev/null +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -0,0 +1,370 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + AuthOrchestrationReadScope, + AuthSessionId, + EnvironmentAuthenticatedAuth, + EnvironmentAuthenticatedPrincipal, + EnvironmentHttpApi, + EventId, + MessageId, + NodeId, + ORCHESTRATION_PROTOCOL_HEADER, + ORCHESTRATION_PROTOCOL_VERSION, + OrchestrationV2ThreadBoundedSnapshot, + OrchestrationV2ThreadStreamItem, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, + TurnItemId, + type OrchestrationV2DomainEvent, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; +import { Etag, HttpRouter } from "effect/http"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; +import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; +import { subscribeOrchestrationV2Thread } from "../ws.ts"; +import * as OrchestrationHttp from "./http.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as ThreadManagementService from "./ThreadManagementService.ts"; + +const decodeBounded = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ThreadBoundedSnapshot)), +); +const decodeStreamItem = Schema.decodeUnknownSync( + Schema.toCodecJson(OrchestrationV2ThreadStreamItem), +); +const encodeStreamItem = Schema.encodeSync(Schema.toCodecJson(OrchestrationV2ThreadStreamItem)); + +const provider = ProviderInstanceId.make("codex"); +const modelSelection = { instanceId: provider, model: "gpt-5.4" }; +const projectId = ProjectId.make("project:compact-transport"); +const PARENT = ThreadId.make("thread:compact-transport:parent"); +const FORK = ThreadId.make("thread:compact-transport:fork"); +const LONG = ThreadId.make("thread:compact-transport:long"); +const parentRun = RunId.make("run:compact-transport:parent"); +const longRun = RunId.make("run:compact-transport:long"); + +const store = Layer.mergeAll( + ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), + SqlitePersistence.layerMemory, +); +const management = Layer.unwrap( + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + return Layer.mock(ThreadManagementService.ThreadManagementService)({ + ensureLegacyTranscript: () => Effect.void, + getThreadSnapshot: (id) => projections.getThreadSnapshot(id).pipe(Effect.orDie), + getThreadSnapshotWindow: (id, options) => + projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie), + streamStoredEventsFrom: () => Stream.empty, + }); + }), +); + +class OrchestrationApi extends HttpApi.make("environment").add( + EnvironmentHttpApi.groups.orchestration, +) {} +const auth = Layer.succeed(EnvironmentAuthenticatedAuth, (effect) => + effect.pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, { + sessionId: AuthSessionId.make("compact-transport"), + subject: "compact-transport", + method: "browser-session-cookie", + scopes: new Set([AuthOrchestrationReadScope]), + }), + ), +); + +function threadCreated(id: ThreadId, at: DateTime.Utc, forkOf?: RunId): OrchestrationV2DomainEvent { + return { + id: EventId.make(`event:${id}:created`), + type: "thread.created", + threadId: id, + occurredAt: at, + payload: { + createdBy: "user", + creationSource: "web", + id, + projectId, + title: String(id), + providerInstanceId: provider, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: + forkOf === undefined + ? { parentThreadId: null, relationshipToParent: null, rootThreadId: id } + : { parentThreadId: PARENT, relationshipToParent: "fork", rootThreadId: PARENT }, + forkedFrom: forkOf === undefined ? null : { type: "run", threadId: PARENT, runId: forkOf }, + createdAt: at, + updatedAt: at, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }; +} + +function runCreated( + threadId: ThreadId, + runId: RunId, + at: DateTime.Utc, +): OrchestrationV2DomainEvent { + const rootNodeId = NodeId.make(`node:${runId}`); + return { + id: EventId.make(`event:${runId}:created`), + type: "run.created", + threadId, + runId, + nodeId: rootNodeId, + driver: ProviderDriverKind.make("codex"), + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: provider, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`message:${runId}`), + rootNodeId, + activeAttemptId: null, + status: "completed", + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }; +} + +function itemUpdated( + threadId: ThreadId, + runId: RunId | null, + key: string, + ordinal: number, + at: DateTime.Utc, + type: + | "command_execution" + | "run_interrupt_request" + | "run_interrupt_result" = "command_execution", +): OrchestrationV2DomainEvent { + const base = { + id: TurnItemId.make(`turn-item:${threadId}:${key}`), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: key, + startedAt: at, + completedAt: at, + updatedAt: at, + }; + const payload: OrchestrationV2TurnItem = + type === "command_execution" + ? { ...base, type, input: `echo ${key}`, output: `output ${key}`, exitCode: 0 } + : { ...base, type, message: key }; + return { + id: EventId.make(`event:${threadId}:${key}`), + type: "turn-item.updated", + threadId, + ...(runId === null ? {} : { runId }), + occurredAt: at, + payload, + }; +} + +const seed = Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const at = yield* DateTime.now; + const events: OrchestrationV2DomainEvent[] = [ + threadCreated(PARENT, at), + runCreated(PARENT, parentRun, at), + ...[1, 2, 3].map((n) => itemUpdated(PARENT, parentRun, `parent-${n}`, n, at)), + threadCreated(FORK, at, parentRun), + ...[1, 2, 3, 4].map((n) => itemUpdated(FORK, null, `fork-${n}`, n, at)), + threadCreated(LONG, at), + runCreated(LONG, longRun, at), + // The request sits far outside the recent window; its result is inside it. + itemUpdated(LONG, longRun, "request", 1, at, "run_interrupt_request"), + ...Array.from({ length: 90 }, (_, index) => + itemUpdated(LONG, longRun, `tool-${index}`, index + 2, at), + ), + itemUpdated(LONG, longRun, "result", 92, at, "run_interrupt_result"), + ]; + for (const event of events) yield* projections.apply(event); +}); + +// Bounded subscriptions without afterSequence never read the event store, and +// thread routes never touch projects. +const TestLayer = Layer.mergeAll( + management, + Layer.effectDiscard(seed), + Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + Layer.mock(ProjectEnrichmentService.ProjectEnrichmentService)({}), +).pipe(Layer.provideMerge(store)); + +const withHttp = (use: (get: (path: string) => Promise) => Promise) => + Effect.gen(function* () { + const context = yield* Effect.context>(); + const appLayer = HttpApiBuilder.layer(OrchestrationApi).pipe( + Layer.provide(OrchestrationHttp.layer), + Layer.provide(auth), + Layer.provide(Layer.succeedContext(context)), + Layer.provide(NodeHttpPlatform.layer), + Layer.provide(Etag.layerWeak), + Layer.provide(NodeServices.layer), + ); + return yield* Effect.acquireUseRelease( + Effect.sync(() => HttpRouter.toWebHandler(appLayer, { disableLogger: true })), + ({ handler }) => + Effect.promise(() => + use((path) => + handler( + new Request(`http://environment.test${path}`, { + headers: { + [ORCHESTRATION_PROTOCOL_HEADER]: String(ORCHESTRATION_PROTOCOL_VERSION), + }, + }), + ), + ), + ), + ({ dispose }) => Effect.promise(() => dispose()), + ); + }); + +const firstSnapshot = (input: Parameters[0]) => + subscribeOrchestrationV2Thread(input).pipe( + Effect.flatMap((stream) => Stream.runHead(stream)), + Effect.map((head) => { + if (head._tag !== "Some" || head.value.kind !== "snapshot") throw new Error("No snapshot"); + // Through the wire codec, as a client receives it. + const decoded = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(head.value)))); + if (decoded.kind !== "snapshot") throw new Error("No snapshot"); + return { raw: head.value, decoded }; + }), + ); + +function itemIds(projection: OrchestrationV2ThreadProjection): string[] { + return projection.turnItems.map((item) => String(item.id)); +} + +it.layer(TestLayer)("compact bounded snapshot transport", (it) => { + describe.each([ + ["fork", FORK], + ["long run with a retained interrupt request", LONG], + ])("%s", (_, threadId) => { + it.effect("HTTP: legacy, ignored and opted-in requests restore to the same snapshot", () => + withHttp(async (get) => { + const base = `/api/orchestration/threads/${encodeURIComponent(threadId)}/bounded`; + const legacyBody = await (await get(base)).text(); + const ignoredBody = await (await get(`${base}?compactTurnItems=yes&unknown=1`)).text(); + const compactResponse = await get(`${base}?compactTurnItems=1`); + assert.strictEqual(compactResponse.status, 200); + const compactBody = await compactResponse.text(); + + // Requests without the exact opt-in get the unchanged representation. + assert.notInclude(legacyBody, "turnItemsOmitLocalVisible"); + assert.strictEqual(ignoredBody, legacyBody); + assert.isBelow(compactBody.length, legacyBody.length); + + const legacy = decodeBounded(legacyBody); + const compact = decodeBounded(compactBody); + assert.isTrue(compact.turnItemsOmitLocalVisible); + const { turnItemsOmitLocalVisible: _marker, projection: _p, ...compactRest } = compact; + const { projection: _legacyProjection, ...legacyRest } = legacy; + assert.deepStrictEqual(compactRest, legacyRest); + assert.deepStrictEqual(boundedSnapshotProjection(compact), legacy.projection); + return { legacy, compact }; + }).pipe( + Effect.tap(({ legacy }) => + Effect.sync(() => { + if (threadId === FORK) { + const visibility = legacy.projection.visibleTurnItems.map((row) => row.visibility); + assert.deepStrictEqual(visibility, [ + "inherited", + "inherited", + "inherited", + "synthetic", + "local", + "local", + "local", + "local", + ]); + } else { + const ids = itemIds(legacy.projection); + assert.isTrue(legacy.hasMoreHistory); + // Window rows first, then the retained request outside the window. + assert.strictEqual(ids.at(-1), `turn-item:${LONG}:request`); + assert.isFalse( + legacy.projection.visibleTurnItems.some((row) => row.sourceItemId === ids.at(-1)), + ); + } + }), + ), + ), + ); + + it.effect("WS: bounded snapshots stay unchanged unless the client opts in", () => + Effect.gen(function* () { + const legacy = yield* firstSnapshot({ threadId, acceptBoundedSnapshot: true }); + const compact = yield* firstSnapshot({ + threadId, + acceptBoundedSnapshot: true, + acceptCompactTurnItems: true, + }); + // Full snapshots ignore the compact opt-in entirely. + const full = yield* firstSnapshot({ threadId, acceptCompactTurnItems: true }); + + assert.isUndefined(legacy.decoded.turnItemsOmitLocalVisible); + assert.isUndefined(full.decoded.turnItemsOmitLocalVisible); + assert.isUndefined(full.decoded.historyCursor); + assert.isTrue(compact.decoded.turnItemsOmitLocalVisible); + assert.isBelow( + compact.decoded.projection.turnItems.length, + legacy.decoded.projection.turnItems.length, + ); + assert.deepStrictEqual( + boundedSnapshotProjection(compact.decoded), + legacy.decoded.projection, + ); + assert.deepStrictEqual( + { + ...compact.decoded, + projection: legacy.decoded.projection, + turnItemsOmitLocalVisible: undefined, + }, + { ...legacy.decoded, turnItemsOmitLocalVisible: undefined }, + ); + }), + ); + }); +}); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index cd433d3621ad..e6b769d98c40 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -32,6 +32,7 @@ import { import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProjectStore from "./ProjectStore.ts"; import { buildActiveShellSnapshot } from "./ShellStream.ts"; +import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; function isThreadNotFound(error: unknown): boolean { @@ -211,11 +212,10 @@ export const layer = HttpApiBuilder.group( }); return { snapshotSequence: snapshot.snapshotSequence, - projection: bounded.projection, - historyCursor: bounded.historyCursor, - hasMoreHistory: bounded.hasMoreHistory, - latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, - payloadBudgetExceeded: bounded.payloadBudgetExceeded, + ...boundedSnapshotResponseFields({ + bounded, + compactTurnItems: args.query.compactTurnItems === "1", + }), }; }), ) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 55d5ec85b5a5..53bf27800583 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -748,6 +748,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; + readonly acceptCompactTurnItems?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -841,7 +842,10 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ); const { snapshotSequence } = snapshot; const snapshotItem = useBoundedSnapshot - ? buildBoundedThreadStreamSnapshot(snapshot) + ? buildBoundedThreadStreamSnapshot({ + ...snapshot, + compactTurnItems: input.acceptCompactTurnItems === true, + }) : { kind: "snapshot" as const, snapshotSequence, diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts index 48d26b31f70c..906f06fb95aa 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts @@ -76,7 +76,84 @@ const FULL_SNAPSHOT_BODY = { }, }; +const AT = "2026-06-20T00:00:00.000Z"; +const command = (id: string, ordinal: number) => ({ + id, + type: "command_execution", + threadId: String(THREAD_ID), + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + input: id, + exitCode: 0, + startedAt: AT, + completedAt: AT, + updatedAt: AT, +}); +const LOCAL_ITEMS = [command("local-1", 1), command("local-2", 2)]; +const DEPENDENCY = command("older-dependency", 0); +const VISIBLE = LOCAL_ITEMS.map((item, position) => ({ + position, + visibility: "local", + sourceThreadId: String(THREAD_ID), + sourceItemId: item.id, + item, +})); +const boundedBody = (compact: boolean) => ({ + snapshotSequence: 12, + projection: { + ...FULL_SNAPSHOT_BODY.projection, + turnItems: compact ? [DEPENDENCY] : [...LOCAL_ITEMS, DEPENDENCY], + visibleTurnItems: VISIBLE, + }, + historyCursor: "bounded-cursor", + hasMoreHistory: true, + latestLocalTurnOrdinal: 2, + payloadBudgetExceeded: false, + ...(compact ? { turnItemsOmitLocalVisible: true } : {}), +}); + describe("boundedThreadSnapshotLoader", () => { + it.effect.each([ + ["an older server that ignores the opt-in", false], + ["a server that sends compact turnItems", true], + ] as const)("requests compact turnItems and loads %s", ([, compact]) => { + const urls: string[] = []; + const fetchFn = ((input: RequestInfo | URL) => { + urls.push(String(input)); + return Promise.resolve(Response.json(boundedBody(compact))); + }) satisfies typeof fetch; + + return Effect.gen(function* () { + const loader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader; + const result = yield* loader.load(PREPARED, THREAD_ID); + expect(new URL(urls[0]!).searchParams.get("compactTurnItems")).toBe("1"); + expect(result._tag).toBe("present"); + if (result._tag !== "present") return; + expect(result.snapshot.projection.turnItems.map((item) => String(item.id))).toEqual([ + "local-1", + "local-2", + "older-dependency", + ]); + expect("turnItemsOmitLocalVisible" in result.snapshot).toBe(false); + expect(result.history).toEqual({ + historyCursor: "bounded-cursor", + hasMoreHistory: true, + latestLocalTurnOrdinal: 2, + }); + }).pipe( + Effect.provide( + Layer.provide(BoundedThreadSnapshotHttp.layer, RpcHttp.layerRemoteHttpClient(fetchFn)), + ), + ); + }); + it.effect("falls back to full HTTP snapshot when bounded returns a plain route 404", () => { const fetchFn = ((input: RequestInfo | URL) => { const url = String(input); diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index ffad41874c94..a049a6b14863 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -1,4 +1,5 @@ import type { ThreadId } from "@t3tools/contracts"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -18,7 +19,11 @@ import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; // Same cold-open budget as the full snapshot path; bounded payloads should fit. const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; -/** Load a bounded recent-window thread snapshot over HTTP. */ +/** + * Load a bounded recent-window thread snapshot over HTTP. Opts into compact + * turnItems and restores them, so callers always see the full bounded shape. + * Older servers ignore the query and send the full shape. + */ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentBoundedThreadSnapshot", )(function* (input: { @@ -40,9 +45,16 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( request: ({ client, headers }) => client.threadBoundedSnapshot({ params: { threadId: input.threadId }, + query: { compactTurnItems: "1" }, headers: withOrchestrationProtocolHeader(headers), }), - }); + }).pipe( + // Drop the marker with the restore so nothing can restore twice. + Effect.map(({ turnItemsOmitLocalVisible, ...snapshot }) => ({ + ...snapshot, + projection: boundedSnapshotProjection({ ...snapshot, turnItemsOmitLocalVisible }), + })), + ); }); /** diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index a832d46b6732..d6cbbf6a7f42 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -122,6 +122,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const lastSubscribeAfterSequence = yield* Ref.make(undefined); const lastRequestCompletionMarker = yield* Ref.make(false); const lastAcceptBoundedSnapshot = yield* Ref.make(undefined); + const lastAcceptCompactTurnItems = yield* Ref.make(undefined); const wakeups = yield* Queue.unbounded(); const savedThreads = yield* Ref.make>([]); const removedThreads = yield* Ref.make>([]); @@ -139,6 +140,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o readonly afterSequence?: number; readonly requestCompletionMarker?: true; readonly acceptBoundedSnapshot?: true; + readonly acceptCompactTurnItems?: true; }) => Stream.unwrap( Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( @@ -147,6 +149,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o Ref.set(lastRequestCompletionMarker, input.requestCompletionMarker === true), ), Effect.andThen(Ref.set(lastAcceptBoundedSnapshot, input.acceptBoundedSnapshot)), + Effect.andThen(Ref.set(lastAcceptCompactTurnItems, input.acceptCompactTurnItems)), Effect.as(streamFrom(inputs)), ), ), @@ -261,6 +264,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o lastSubscribeAfterSequence, lastRequestCompletionMarker, lastAcceptBoundedSnapshot, + lastAcceptCompactTurnItems, supervisorState, supervisorSession, savedThreads, @@ -842,6 +846,113 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("restores compact socket snapshot turnItems before reducing and caching", () => + Effect.gen(function* () { + const at = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); + const command = (id: string, ordinal: number, threadId = THREAD_ID) => + ({ + id: TurnItemId.make(id), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "command_execution" as const, + input: id, + output: id, + exitCode: 0, + }) satisfies OrchestrationV2TurnItem; + const parentThread = ThreadId.make("thread:compact-parent"); + const inherited = command("parent-item", 1, parentThread); + const local = [command("local-1", 1), command("local-2", 2)]; + const retainedRequest = { + ...command("retained-request", 0), + type: "run_interrupt_request" as const, + message: "Stop", + } as unknown as OrchestrationV2TurnItem; + const visibleTurnItems = [ + { + position: 0, + visibility: "inherited" as const, + sourceThreadId: parentThread, + sourceItemId: inherited.id, + item: inherited, + }, + ...local.map((item, index) => ({ + position: index + 1, + visibility: "local" as const, + sourceThreadId: THREAD_ID, + sourceItemId: item.id, + item, + })), + ]; + const harness = yield* makeHarness(); + yield* Queue.offer(harness.inputs, { + kind: "snapshot", + snapshotSequence: 5, + // Only the dependency that is not a visible local row is on the wire. + projection: { ...BASE_PROJECTION, turnItems: [retainedRequest], visibleTurnItems }, + historyCursor: "compact-cursor", + hasMoreHistory: true, + latestLocalTurnOrdinal: 2, + payloadBudgetExceeded: false, + turnItemsOmitLocalVisible: true, + }); + + const seeded = yield* awaitThreadState( + harness.observed, + (value) => value.history.historyCursor === "compact-cursor", + ); + expect(Option.getOrThrow(seeded.data).turnItems).toEqual([...local, retainedRequest]); + expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); + expect(yield* Ref.get(harness.lastAcceptCompactTurnItems)).toBe(true); + + // Live updates reduce against the restored list, not the compact one. + const updated = { ...local[1]!, output: "updated", ordinal: 2 }; + yield* Queue.offer(harness.inputs, { + kind: "event", + sequence: 6, + event: { + id: EventId.make("event-compact-update"), + type: "turn-item.updated", + threadId: THREAD_ID, + occurredAt: at, + payload: updated, + }, + }); + const live = yield* awaitThreadState(harness.observed, (value) => + Option.exists(value.data, (data) => + data.turnItems.some( + (item) => item.type === "command_execution" && item.output === "updated", + ), + ), + ); + expect(Option.getOrThrow(live.data).turnItems.map((item) => String(item.id))).toEqual([ + "local-1", + "local-2", + "retained-request", + ]); + + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + const saved = (yield* Ref.get(harness.savedThreads)).at(-1); + expect(saved?.projection.turnItems.map((item) => String(item.id))).toEqual([ + "local-1", + "local-2", + "retained-request", + ]); + expect(saved && "turnItemsOmitLocalVisible" in saved).toBe(false); + }), + ); + it.effect("seeds the thread from the HTTP snapshot and resumes live events", () => Effect.gen(function* () { const httpProjection: OrchestrationV2ThreadProjection = { diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 62df7cd30dfb..5c64c41b1d01 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -6,6 +6,7 @@ import { type OrchestrationV2ThreadStreamItem, type ThreadId as ThreadIdType, } from "@t3tools/contracts"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -589,7 +590,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make // Bounded socket fallbacks carry their cursor. Legacy-compatible full // snapshots omit these fields and still replace progressive state. yield* setThread( - item.projection, + boundedSnapshotProjection(item), hasProgressiveHistory ? { history: { @@ -919,7 +920,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make threadId, ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), - ...(acceptBoundedSnapshot ? { acceptBoundedSnapshot: true as const } : {}), + ...(acceptBoundedSnapshot + ? { acceptBoundedSnapshot: true as const, acceptCompactTurnItems: true as const } + : {}), }; }), { diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index a722ba064bd3..b74affb86216 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -584,6 +584,14 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +/** + * `compactTurnItems=1` opts into `turnItemsOmitLocalVisible`. Other values are + * ignored, and older servers ignore the whole query. + */ +const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ + compactTurnItems: Schema.optionalKey(Schema.String), +}); + const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ cursor: TrimmedNonEmptyString, }); @@ -615,6 +623,7 @@ class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") HttpApiEndpoint.get("threadBoundedSnapshot", "/api/orchestration/threads/:threadId/bounded", { headers: OrchestrationProtocolHeaders, params: EnvironmentOrchestrationThreadSnapshotParams, + query: EnvironmentOrchestrationThreadBoundedSnapshotQuery, success: OrchestrationV2ThreadBoundedSnapshot, error: EnvironmentOrchestrationThreadSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index e8fd216de980..cd185f908d5c 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -45,6 +45,48 @@ import { } from "./orchestrationV2.ts"; const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z"); +function emptyThreadProjection() { + return { + thread: { + createdBy: "user", + creationSource: "web", + id: "thread-1", + projectId: "project-1", + title: "Thread", + providerInstanceId: "codex", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: "thread-1" }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + }, + runs: [], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [], + updatedAt: now, + }; +} + const LegacyShellStreamItem = Schema.Union([ Schema.Struct({ kind: Schema.Literal("synchronized") }), Schema.Struct({ @@ -326,6 +368,47 @@ describe("orchestration V2 contracts", () => { expect("acceptBoundedSnapshot" in legacyDecoded).toBe(false); }); + it("negotiates compact bounded turnItems without disturbing older peers", () => { + // Older servers strip the unknown opt-in, so they keep sending full turnItems. + const legacyDecoded = decodeLegacySubscribeThreadInput({ + threadId: "thread-1", + acceptBoundedSnapshot: true, + acceptCompactTurnItems: true, + }); + expect("acceptCompactTurnItems" in legacyDecoded).toBe(false); + expect( + decodeOrchestrationV2SubscribeThreadInput({ + threadId: "thread-1", + acceptCompactTurnItems: true, + }).acceptCompactTurnItems, + ).toBe(true); + + const decodeStreamItem = Schema.decodeUnknownSync(OrchestrationV2ThreadStreamItem); + const projection = decodeStreamItem({ + kind: "snapshot", + snapshotSequence: 1, + projection: emptyThreadProjection(), + }); + // Older servers never send the marker; newer clients treat absence as full turnItems. + expect(projection.kind === "snapshot" && projection.turnItemsOmitLocalVisible).toBe(undefined); + const marked = decodeStreamItem({ + kind: "snapshot", + snapshotSequence: 1, + projection: emptyThreadProjection(), + turnItemsOmitLocalVisible: true, + }); + expect(marked.kind === "snapshot" && marked.turnItemsOmitLocalVisible).toBe(true); + // The marker only means "omitted"; any other value is a protocol error. + expect(() => + decodeStreamItem({ + kind: "snapshot", + snapshotSequence: 1, + projection: emptyThreadProjection(), + turnItemsOmitLocalVisible: false, + }), + ).toThrow(); + }); + it("decodes persisted capability snapshots that predate runtimePolicy", () => { // Events written before the field existed must replay; absent decodes to // the weaker client-boundary guarantee so history never overclaims. diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c26d5a9327e4..abc226e356df 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3244,6 +3244,11 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), /** Allows snapshot fallbacks to contain a bounded, pageable history window. */ acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean), + /** + * Allows bounded snapshot fallbacks to omit `projection.turnItems` entries + * that repeat local visible rows. See `turnItemsOmitLocalVisible`. + */ + acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeThreadInput = typeof OrchestrationV2SubscribeThreadInput.Type; @@ -3283,6 +3288,12 @@ export const OrchestrationV2ThreadBoundedSnapshot = Schema.Struct({ latestLocalTurnOrdinal: Schema.NullOr(NonNegativeInt), /** True when complete turns or required live control state exceed the usual byte budget. */ payloadBudgetExceeded: Schema.optional(Schema.Boolean), + /** + * Set only for clients that opted in: `projection.turnItems` omits the items + * of local visible rows, which lead the full list. Clients must restore them + * with `boundedSnapshotProjection` before using the projection. + */ + turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), }); export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadBoundedSnapshot.Type; @@ -3363,6 +3374,8 @@ export const OrchestrationV2ThreadStreamItem = Schema.Union([ hasMoreHistory: Schema.optionalKey(Schema.Boolean), latestLocalTurnOrdinal: Schema.optionalKey(Schema.NullOr(NonNegativeInt)), payloadBudgetExceeded: Schema.optionalKey(Schema.Boolean), + /** Same meaning as on `OrchestrationV2ThreadBoundedSnapshot`. */ + turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), }), Schema.Struct({ kind: Schema.Literal("event"), diff --git a/packages/shared/package.json b/packages/shared/package.json index 50cc08129d8e..4025994af076 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -359,6 +359,10 @@ "types": "./src/nodeSqliteClient.ts", "import": "./src/nodeSqliteClient.ts" }, + "./orchestrationV2BoundedSnapshot": { + "types": "./src/orchestrationV2BoundedSnapshot.ts", + "import": "./src/orchestrationV2BoundedSnapshot.ts" + }, "./orchestrationV2Timeline": { "types": "./src/orchestrationV2Timeline.ts", "import": "./src/orchestrationV2Timeline.ts" diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts new file mode 100644 index 000000000000..cf231c0617ef --- /dev/null +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts @@ -0,0 +1,147 @@ +import { + RunId, + ThreadId, + TurnItemId, + type OrchestrationV2ProjectedTurnItem, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + boundedSnapshotProjection, + omitLocalVisibleTurnItems, + restoreLocalVisibleTurnItems, +} from "./orchestrationV2BoundedSnapshot.ts"; + +const PARENT = ThreadId.make("thread:compact-parent"); +const CHILD = ThreadId.make("thread:compact-child"); +const RUN = RunId.make("run:compact"); + +function item( + threadId: ThreadId, + id: string, + ordinal: number, + type: "command_execution" | "run_interrupt_request" | "fork" = "command_execution", +): OrchestrationV2TurnItem { + return { + id: TurnItemId.make(id), + type, + threadId, + runId: type === "command_execution" ? null : RUN, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: id, + input: id, + exitCode: 0, + startedAt: null, + completedAt: null, + updatedAt: "2026-10-01T00:00:00.000Z", + } as unknown as OrchestrationV2TurnItem; +} + +function row( + visibility: OrchestrationV2ProjectedTurnItem["visibility"], + sourceThreadId: ThreadId, + value: OrchestrationV2TurnItem, +): Omit { + return { visibility, sourceThreadId, sourceItemId: value.id, item: value }; +} + +function projection(input: { + readonly visible: ReadonlyArray>; + readonly turnItems: ReadonlyArray; +}): OrchestrationV2ThreadProjection { + return { + thread: { id: CHILD }, + turnItems: [...input.turnItems], + visibleTurnItems: input.visible.map((value, position) => ({ ...value, position })), + } as unknown as OrchestrationV2ThreadProjection; +} + +/** Fork child: inherited parent rows, synthetic marker, local rows, retained interrupt request. */ +function forkWindow() { + const inherited = [item(PARENT, "parent-1", 1), item(PARENT, "parent-2", 2)]; + const marker = item(PARENT, "fork-marker", 3, "fork"); + const local = [item(CHILD, "child-1", 1), item(CHILD, "child-2", 2), item(CHILD, "child-3", 3)]; + const retainedRequest = item(CHILD, "interrupt-request", 0, "run_interrupt_request"); + return { + local, + retainedRequest, + projection: projection({ + visible: [ + ...inherited.map((value) => row("inherited", PARENT, value)), + row("synthetic", PARENT, marker), + ...local.map((value) => row("local", CHILD, value)), + ], + turnItems: [...local, retainedRequest], + }), + }; +} + +describe("compact bounded snapshot turnItems", () => { + it("omits only local visible items and restores the exact list and order", () => { + const { projection: full, local, retainedRequest } = forkWindow(); + const compact = omitLocalVisibleTurnItems(full); + + expect(compact).not.toBeNull(); + // Inherited and synthetic rows never had turnItems; the retained request stays. + expect(compact!.turnItems).toEqual([retainedRequest]); + expect(compact!.visibleTurnItems).toBe(full.visibleTurnItems); + + const restored = restoreLocalVisibleTurnItems(compact!); + expect(restored.turnItems).toEqual([...local, retainedRequest]); + expect(restored).toEqual(full); + }); + + it("round-trips through JSON as a separately decoded client would see it", () => { + const { projection: full } = forkWindow(); + const wire = JSON.parse(JSON.stringify(omitLocalVisibleTurnItems(full))); + expect( + boundedSnapshotProjection({ projection: wire, turnItemsOmitLocalVisible: true }), + ).toEqual(JSON.parse(JSON.stringify(full))); + }); + + it("treats rows sourced from the thread itself as local", () => { + const own = item(CHILD, "own", 1); + const full = projection({ visible: [row("inherited", CHILD, own)], turnItems: [own] }); + const compact = omitLocalVisibleTurnItems(full); + expect(compact?.turnItems).toEqual([]); + expect(restoreLocalVisibleTurnItems(compact!)).toEqual(full); + }); + + it("refuses shapes it cannot restore exactly", () => { + const [first, second] = [item(CHILD, "a", 1), item(CHILD, "b", 2)]; + const visible = [row("local", CHILD, first), row("local", CHILD, second)]; + // Different order than the visible rows. + expect(omitLocalVisibleTurnItems(projection({ visible, turnItems: [second, first] }))).toBe( + null, + ); + // Equal content but a different object: restoring would not be provably exact. + expect( + omitLocalVisibleTurnItems(projection({ visible, turnItems: [{ ...first }, second] })), + ).toBe(null); + // A local row without its turn item. + expect(omitLocalVisibleTurnItems(projection({ visible, turnItems: [first] }))).toBe(null); + }); + + it("sends nothing compact when no local rows are visible", () => { + const inherited = item(PARENT, "parent", 1); + const request = item(CHILD, "interrupt-request", 0, "run_interrupt_request"); + expect( + omitLocalVisibleTurnItems( + projection({ visible: [row("inherited", PARENT, inherited)], turnItems: [request] }), + ), + ).toBe(null); + }); + + it("leaves unmarked snapshots untouched", () => { + const { projection: full } = forkWindow(); + expect(boundedSnapshotProjection({ projection: full })).toBe(full); + }); +}); diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.ts new file mode 100644 index 000000000000..117306819cd2 --- /dev/null +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.ts @@ -0,0 +1,58 @@ +import type { + OrchestrationV2ProjectedTurnItem, + OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; + +/** Local timeline rows also carry their item in `projection.turnItems`. */ +function isLocalTimelineRow( + projection: Pick, + row: OrchestrationV2ProjectedTurnItem, +): boolean { + return row.visibility === "local" || row.sourceThreadId === projection.thread.id; +} + +/** + * Server half of the opt-in compact bounded snapshot. Bounded `turnItems` start + * with the items of the window's local visible rows, so those entries can be + * dropped and rebuilt by `restoreLocalVisibleTurnItems`. Returns null when the + * projection does not have that exact shape, or when nothing would be omitted; + * callers then send the unchanged projection without a marker. + */ +export function omitLocalVisibleTurnItems( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection | null { + let omitted = 0; + for (const row of projection.visibleTurnItems) { + if (!isLocalTimelineRow(projection, row)) continue; + // Same object, not merely the same id: restoring from the row must + // reproduce the original entry exactly. + if (projection.turnItems[omitted] !== row.item) return null; + omitted += 1; + } + if (omitted === 0) return null; + return { ...projection, turnItems: projection.turnItems.slice(omitted) }; +} + +/** + * Client half: call on a decoded snapshot that carries the + * `turnItemsOmitLocalVisible` marker, before reducers or caches see it. + */ +export function restoreLocalVisibleTurnItems( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection { + const local = projection.visibleTurnItems + .filter((row) => isLocalTimelineRow(projection, row)) + .map((row) => row.item); + if (local.length === 0) return projection; + return { ...projection, turnItems: [...local, ...projection.turnItems] }; +} + +/** The usable projection of a decoded bounded snapshot, compact or not. */ +export function boundedSnapshotProjection(snapshot: { + readonly projection: OrchestrationV2ThreadProjection; + readonly turnItemsOmitLocalVisible?: true | undefined; +}): OrchestrationV2ThreadProjection { + return snapshot.turnItemsOmitLocalVisible === true + ? restoreLocalVisibleTurnItems(snapshot.projection) + : snapshot.projection; +} From eb17e0db2bd075e71b4015dc57588e7f838cbcdc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:30:48 -0700 Subject: [PATCH 014/273] perf(mobile): pause elapsed-time timers on hidden thread screens (#15397) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/package.json | 1 + .../features/threads/ProviderSubagentBar.tsx | 9 +- .../threads/floating-working-control.tsx | 7 +- .../threads/use-visible-second-clock.test.tsx | 199 ++++++++++++++++++ .../threads/use-visible-second-clock.ts | 34 +++ pnpm-lock.yaml | 3 + 6 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-visible-second-clock.test.tsx create mode 100644 apps/mobile/src/features/threads/use-visible-second-clock.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ca295e389fe2..b8d8dea0d058 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -135,6 +135,7 @@ "@types/react": "~19.3.0", "@types/react-dom": "~19.2.3", "babel-preset-expo": "~58.0.8", + "jsdom": "^30.0.1", "tailwindcss": "^4.0.0", "typescript": "catalog:" }, diff --git a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx index 813b64148901..fc93df0cd7a9 100644 --- a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx +++ b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx @@ -3,12 +3,12 @@ import { type ProviderSubagentStatus, } from "@t3tools/client-runtime/state/thread-execution"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; -import { useEffect, useState } from "react"; import { View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { RequestActionButton } from "./RequestActionButton"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; /** * Replaces the composer on a provider-native subagent thread. The provider @@ -26,12 +26,7 @@ export function ProviderSubagentBar(props: { readonly onOpenParent: (() => void) | null; }) { const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status); - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - if (!live) return; - const id = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(id); - }, [live]); + const nowMs = useVisibleSecondClock(live); const statusLabel = formatProviderSubagentStatus(props.status, nowMs); const modelDescription = props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`; diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 2e032eb8ba5e..f21a83f09a17 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -30,6 +30,7 @@ import { BrowserPreviewButton } from "../browser/browser-preview-button"; import { DevicePreviewButton } from "../devices/device-preview-button"; import type { FloatingWorkingStatus } from "./floating-working-status"; import { ShimmeringWorkContent } from "./thread-work-log"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem // The collapsed composer capsule starts 6 below its overlay's top edge, so @@ -510,11 +511,7 @@ function WorkingDuration(props: { } export function WorkingTimer(props: { readonly startedAt: string }) { - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(intervalId); - }, []); + const nowMs = useVisibleSecondClock(true); return ( { + const listeners = new Set<() => void>(); + const appStateListeners = new Set<(state: string) => void>(); + const state = { focused: true, appState: "active" }; + return { + state, + appStateListeners, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setFocused(focused: boolean) { + state.focused = focused; + for (const listener of listeners) listener(); + }, + setAppState(next: string) { + state.appState = next; + for (const listener of appStateListeners) listener(next); + }, + }; +}); + +vi.mock("@react-navigation/native", () => ({ + useIsFocused: () => useSyncExternalStore(harness.subscribe, () => harness.state.focused), +})); +vi.mock("react-native", () => ({ + AppState: { + get currentState() { + return harness.state.appState; + }, + addEventListener: (_event: "change", listener: (state: string) => void) => { + harness.appStateListeners.add(listener); + return { remove: () => harness.appStateListeners.delete(listener) }; + }, + }, +})); + +import { useVisibleSecondClock } from "./use-visible-second-clock"; + +const START_MS = Date.UTC(2026, 9, 3, 12, 0, 0); +let root: Root | null = null; +// Clock value of every committed render, the work a tick costs on device. +let renders: number[] = []; + +function Probe(props: { readonly enabled: boolean }) { + const nowMs = useVisibleSecondClock(props.enabled); + useEffect(() => { + renders.push(nowMs); + }); + return null; +} + +function mount(enabled: boolean) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => root!.render()); +} + +// One act per second, so each tick commits its own render as it would on device. +function advance(ms: number) { + for (let elapsed = 0; elapsed < ms; elapsed += 1_000) { + act(() => vi.advanceTimersByTime(Math.min(1_000, ms - elapsed))); + } +} + +function latest() { + return renders.at(-1); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + harness.state.focused = true; + harness.state.appState = "active"; + renders = []; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + vi.useRealTimers(); +}); + +describe("useVisibleSecondClock", () => { + it("ticks once a second while enabled, focused and active", () => { + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(60); + expect(latest()).toBe(START_MS + 60_000); + }); + + it("does not wake while the app is in the background", () => { + harness.state.appState = "background"; + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not wake while the screen is unfocused", () => { + harness.state.focused = false; + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not wake when disabled, even when visible", () => { + mount(false); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + }); + + it("notices an AppState change between first render and subscribing", () => { + // Layout effects run before the hook's passive subscription, so this change has no listener. + function BackgroundBeforeSubscribe() { + useLayoutEffect(() => harness.setAppState("background"), []); + return null; + } + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => + root!.render( + <> + + + , + ), + ); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("pauses on blur and shows wall-clock elapsed time the moment it refocuses", () => { + mount(true); + advance(5_000); + act(() => harness.setFocused(false)); + const paused = renders.length; + advance(120_000); + expect(renders.length).toBe(paused); + + act(() => harness.setFocused(true)); + expect(latest()).toBe(START_MS + 125_000); + advance(1_000); + expect(latest()).toBe(START_MS + 126_000); + }); + + it("pauses while backgrounded and catches up on the first active render", () => { + mount(true); + act(() => harness.setAppState("background")); + const paused = renders.length; + advance(600_000); + expect(renders.length).toBe(paused); + expect(vi.getTimerCount()).toBe(0); + + act(() => harness.setAppState("inactive")); + advance(10_000); + expect(renders.length).toBe(paused); + + act(() => harness.setAppState("active")); + expect(latest()).toBe(START_MS + 610_000); + const resumed = renders.length; + advance(60_000); + expect(renders.length - resumed).toBe(60); + }); + + it("stops when the work it measures settles and refreshes when new work starts", () => { + mount(true); + advance(3_000); + act(() => root!.render()); + const settled = renders.length; + advance(60_000); + expect(renders.length).toBe(settled); + + act(() => root!.render()); + expect(latest()).toBe(START_MS + 63_000); + }); + + it("clears its interval and AppState subscription on unmount", () => { + mount(true); + expect(vi.getTimerCount()).toBe(1); + expect(harness.appStateListeners.size).toBe(1); + act(() => root!.unmount()); + root = null; + expect(vi.getTimerCount()).toBe(0); + expect(harness.appStateListeners.size).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/threads/use-visible-second-clock.ts b/apps/mobile/src/features/threads/use-visible-second-clock.ts new file mode 100644 index 000000000000..ff98092b231b --- /dev/null +++ b/apps/mobile/src/features/threads/use-visible-second-clock.ts @@ -0,0 +1,34 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEffect, useLayoutEffect, useState } from "react"; +import { AppState } from "react-native"; + +/** + * Wall clock for elapsed-time labels, advanced once a second only while + * `enabled`, the screen is focused, and the app is active. Retained routes stay + * mounted when hidden, so an ungated interval would keep re-rendering them. + * The clock re-reads the time before paint when ticking resumes, so a label + * never shows the moment it paused. + */ +export function useVisibleSecondClock(enabled: boolean): number { + const focused = useIsFocused(); + const [appActive, setAppActive] = useState(() => AppState.currentState === "active"); + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => + setAppActive(state === "active"), + ); + // A change between the first render and this subscription has no event to replay. + // oxlint-disable-next-line react/set-state-in-effect -- Syncs state the listener missed. + setAppActive(AppState.currentState === "active"); + return () => subscription.remove(); + }, []); + const ticking = enabled && focused && appActive; + useLayoutEffect(() => { + if (!ticking) return; + // oxlint-disable-next-line react/set-state-in-effect -- Resuming reads the clock that stood still. + setNowMs(Date.now()); + const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); + return () => clearInterval(intervalId); + }, [ticking]); + return nowMs; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a54dc0d1fa2..bf0256cee1cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,6 +512,9 @@ importers: babel-preset-expo: specifier: ~58.0.8 version: 58.0.8(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@58.0.11)(expo@58.0.2)(react-refresh@0.14.2) + jsdom: + specifier: ^30.0.1 + version: 30.1.0(@noble/hashes@1.8.0) tailwindcss: specifier: 4.3.3 version: 4.3.3 From 65404eb75de9233c9d1068ddfd09bacfc3b0465b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:30:48 -0700 Subject: [PATCH 015/273] fix(mobile): pause hidden home thread list updates (#15705) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/home/HomeRouteScreen.tsx | 12 +- .../home/home-route-visibility.test.tsx | 143 ++++++++++++++++++ .../features/home/home-route-visibility.ts | 57 +++++++ 3 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/features/home/home-route-visibility.test.tsx create mode 100644 apps/mobile/src/features/home/home-route-visibility.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 0f213ca59b07..cd8af281c9f5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -9,7 +9,8 @@ import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useProjects, useNavigationThreadShells } from "../../state/entities"; +import { useProjects } from "../../state/entities"; +import { environmentThreadShells } from "../../state/threads"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -22,6 +23,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; +import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; import { useHomeThreadSelection } from "./home-thread-navigation"; import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; @@ -37,7 +39,13 @@ export function HomeRouteScreen() { const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; const { layout, panes } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); - const threads = useNavigationThreadShells(); + // Streaming turns rewrite thread shells many times a second. While a Thread + // covers Home, rebuilding this list is invisible work. + const visible = useHomeRouteVisible(); + const threads = useAtomValueWhileVisible( + environmentThreadShells.navigationThreadShellsAtom, + visible, + ); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); diff --git a/apps/mobile/src/features/home/home-route-visibility.test.tsx b/apps/mobile/src/features/home/home-route-visibility.test.tsx new file mode 100644 index 000000000000..f1c670119ce1 --- /dev/null +++ b/apps/mobile/src/features/home/home-route-visibility.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { RegistryContext } from "@effect/atom-react"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type Listener = (event: { readonly data?: { readonly closing: boolean } }) => void; + +const harness = vi.hoisted(() => { + const listeners = new Map>(); + const state = { focused: true }; + return { + state, + listeners, + navigation: { + isFocused: () => state.focused, + addListener(type: string, listener: Listener) { + let set = listeners.get(type); + if (set === undefined) { + set = new Set(); + listeners.set(type, set); + } + set.add(listener); + return () => set.delete(listener); + }, + }, + emit(type: string, data?: { readonly closing: boolean }) { + for (const listener of listeners.get(type) ?? []) listener({ data }); + }, + }; +}); + +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => harness.navigation, +})); + +import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; + +const shellsAtom = Atom.make(0).pipe(Atom.keepAlive); +let registry: AtomRegistry.AtomRegistry; +let root: Root | null = null; +// Value of every committed render, the work a shell update costs on device. +let renders: Array<{ readonly visible: boolean; readonly value: number }> = []; + +function Probe() { + const visible = useHomeRouteVisible(); + const value = useAtomValueWhileVisible(shellsAtom, visible); + useEffect(() => { + renders.push({ visible, value }); + }); + return null; +} + +function mount() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => + root!.render( + + + , + ), + ); +} + +function stream(count: number) { + for (let index = 0; index < count; index += 1) { + act(() => registry.update(shellsAtom, (value) => value + 1)); + } +} + +function pushThread() { + act(() => harness.emit("transitionStart", { closing: true })); + act(() => harness.emit("transitionEnd", { closing: true })); +} + +beforeEach(() => { + registry = AtomRegistry.make(); + harness.listeners.clear(); + harness.state.focused = true; + renders = []; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + registry.dispose(); +}); + +describe("Home route visibility", () => { + it("re-renders for every shell update while Home is visible", () => { + mount(); + stream(20); + expect(renders.at(-1)).toEqual({ visible: true, value: 20 }); + expect(renders).toHaveLength(21); + }); + + it("stays live until the covering Thread finishes its push animation", () => { + mount(); + act(() => harness.emit("transitionStart", { closing: true })); + stream(3); + expect(renders.at(-1)).toEqual({ visible: true, value: 3 }); + }); + + it("does no work for shell updates while a Thread covers Home", () => { + mount(); + stream(2); + pushThread(); + const before = renders.length; + stream(600); + expect(renders.length - before).toBe(0); + expect(renders.at(-1)).toEqual({ visible: false, value: 2 }); + }); + + it("shows the freshest list as soon as a back swipe starts revealing Home", () => { + mount(); + pushThread(); + stream(50); + act(() => harness.emit("transitionStart", { closing: false })); + expect(renders.at(-1)).toEqual({ visible: true, value: 50 }); + stream(1); + expect(renders.at(-1)).toEqual({ visible: true, value: 51 }); + }); + + it("refreshes on focus when no appear event arrives", () => { + mount(); + pushThread(); + stream(5); + act(() => harness.emit("focus")); + expect(renders.at(-1)).toEqual({ visible: true, value: 5 }); + }); + + it("starts hidden when a deep link mounts Home under its Thread", () => { + harness.state.focused = false; + act(() => registry.set(shellsAtom, 7)); + mount(); + stream(10); + expect(renders).toEqual([{ visible: false, value: 7 }]); + act(() => harness.emit("transitionStart", { closing: false })); + expect(renders.at(-1)).toEqual({ visible: true, value: 17 }); + }); +}); diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts new file mode 100644 index 000000000000..1d5f5c7aab99 --- /dev/null +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -0,0 +1,57 @@ +import { RegistryContext } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { Atom } from "effect/reactivity"; + +/** + * Whether the Home route can be seen. Home stays mounted under a pushed + * Thread, and native-stack never freezes the route directly below the focused + * one on Fabric, so Home has to stop its own work. + * + * Driven by the native appear/disappear events rather than focus. A form sheet + * over Home blurs it but leaves it on screen, and the presenting screen gets no + * disappear event for it. Home becomes visible as soon as a back swipe or pop + * starts revealing it, so the list is fresh during the transition. + */ +export function useHomeRouteVisible(): boolean { + const navigation = + useNavigation>(); + // A cold deep link mounts Home under its Thread without ever showing it. + const [visible, setVisible] = useState(() => navigation.isFocused()); + useEffect(() => { + const show = () => setVisible(true); + const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => { + if (!data.closing) show(); + }); + const removeTransitionEnd = navigation.addListener("transitionEnd", ({ data }) => { + if (data.closing) setVisible(false); + }); + // Popping a sheet or a deep-linked route above Home has no appear event. + const removeFocus = navigation.addListener("focus", show); + return () => { + removeTransitionStart(); + removeTransitionEnd(); + removeFocus(); + }; + }, [navigation]); + return visible; +} + +/** + * Like `useAtomValue`, but unsubscribed while `visible` is false: the last + * visible value is held and atom changes do not re-render. Resubscribing reads + * the current value in the same render that turns visibility back on. + */ +export function useAtomValueWhileVisible(atom: Atom.Atom, visible: boolean): A { + const registry = useContext(RegistryContext); + const held = useRef<{ readonly value: A } | null>(null); + const subscribe = useCallback( + (onChange: () => void) => (visible ? registry.subscribe(atom, onChange) : () => undefined), + [registry, atom, visible], + ); + return useSyncExternalStore(subscribe, () => { + if (visible || held.current === null) held.current = { value: registry.get(atom) }; + return held.current.value; + }); +} From e803242d936af3e052757d59ab32bc7c4fcc2390 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:33:55 -0700 Subject: [PATCH 016/273] fix(mobile): restore file viewer insets and glass header (#17073) --- .../ios/T3ReviewDiffModule.swift | 8 +++++ .../t3-review-diff/ios/T3ReviewDiffView.swift | 36 ++++++++++++++----- apps/mobile/src/Stack.tsx | 4 +-- .../src/components/ComposerContextSheet.tsx | 1 + .../features/diffs/nativeReviewDiffSurface.ts | 2 ++ .../src/features/files/FileTreeBrowser.tsx | 24 ++++--------- .../src/features/files/SourceFileSurface.tsx | 29 +++++++++++---- .../features/files/ThreadFilesRouteScreen.tsx | 9 +++-- .../files/thread-file-navigator-pane.tsx | 1 - 9 files changed, 77 insertions(+), 37 deletions(-) diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index 96a1785f9fc6..5564dce34af3 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -49,6 +49,14 @@ public class T3ReviewDiffModule: Module { view.setContentWidth(CGFloat(contentWidth)) } + Prop("contentInsetTop") { (view: T3ReviewDiffView, inset: Double) in + view.setContentInsetTop(CGFloat(inset)) + } + + Prop("contentInsetBottom") { (view: T3ReviewDiffView, inset: Double) in + view.setContentInsetBottom(CGFloat(inset)) + } + Prop("initialRowIndex") { (view: T3ReviewDiffView, initialRowIndex: Double) in view.setInitialRowIndex(initialRowIndex) } diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 928e3b5669d1..dfddaf7016c3 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -563,6 +563,24 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { ]) } + // Source files fill the screen so UIKit can sample content below the glass bar. + // Keep the same visible row when header or safe-area reservations change. + func setContentInsetTop(_ inset: CGFloat) { + scrollView.automaticallyAdjustsScrollIndicatorInsets = false + let inset = max(0, inset) + let delta = inset - scrollView.contentInset.top + guard delta != 0 else { return } + scrollView.contentInset.top = inset + scrollView.verticalScrollIndicatorInsets.top = inset + scrollView.contentOffset.y -= delta + updateViewportFrame() + } + + func setContentInsetBottom(_ inset: CGFloat) { + scrollView.contentInset.bottom = max(0, inset) + scrollView.verticalScrollIndicatorInsets.bottom = max(0, inset) + } + func setContentResetKey(_ contentResetKey: String) { guard contentResetKey != self.contentResetKey else { return @@ -577,7 +595,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { hasAppliedInitialRowIndex = false pendingScrollFileId = nil isProgrammaticScrollActive = false - scrollView.setContentOffset(.zero, animated: false) + scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false) updateContentMetrics() updateViewportFrame() lastVisibleFileId = nil @@ -870,8 +888,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) { - let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) - let clampedOffset = min(max(targetOffset, 0), maxOffset) + let topInset = scrollView.contentInset.top + let maxOffset = max( + scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom, + -topInset + ) + let clampedOffset = min(max(targetOffset - topInset, -topInset), maxOffset) let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5 isProgrammaticScrollActive = shouldAnimate contentView.isVerticalScrollActive = shouldAnimate @@ -889,12 +911,10 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { return } - let targetScreenY = max(0, (bounds.height - rowFrame.height) * 0.3) - let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) - let targetOffset = min(max(rowFrame.minY - targetScreenY, 0), maxOffset) + let visibleHeight = bounds.height - scrollView.contentInset.top - scrollView.contentInset.bottom + let targetScreenY = max(0, (visibleHeight - rowFrame.height) * 0.3) hasAppliedInitialRowIndex = true - scrollView.setContentOffset(CGPoint(x: 0, y: targetOffset), animated: false) - updateViewportFrame() + setVerticalContentOffset(rowFrame.minY - targetScreenY, animated: false) } } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b215ba338070..569bfc11ada9 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -159,7 +159,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { }; // SOLID: opaque sheet-colored header for surfaces whose content scrolls internally -// (file viewer, terminal, review) — there is nothing for glass to sample there. +// (terminal, review) — there is nothing for glass to sample there. const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerBackButtonDisplayMode: "minimal", headerBackTitle: "", @@ -750,7 +750,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ThreadFile: createNativeStackScreen({ screen: ThreadFileScreen, linking: `${THREAD_LINKING_PREFIX}/files/:path*`, - options: SOLID_HEADER_OPTIONS, + options: GLASS_HEADER_OPTIONS, }), ThreadMcpApp: createNativeStackScreen({ screen: McpAppFullscreenScreen, diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 208c15268ec1..4fbc02ab1e49 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -47,6 +47,7 @@ function ContextField(props: { label: string; value: string | null | undefined; style={{ height: Math.min(260, Math.max(100, props.value.split("\n").length * 22 + 36)) }} > diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index fda734106687..16208a7d5a2d 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -121,6 +121,8 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly styleJson?: string; readonly rowHeight: number; readonly contentWidth: number; + readonly contentInsetTop?: number; + readonly contentInsetBottom?: number; readonly initialRowIndex?: number; readonly refreshing?: boolean; readonly nativeViewRef?: Ref; diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index ed42f5345abe..4d313b47b49f 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -8,7 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; -import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { buildFileTree, flattenFileTree, @@ -42,7 +42,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; readonly expanded: boolean; - readonly loaded: boolean; readonly loading: boolean; readonly onPressDirectory: (path: string, expand: boolean) => void; readonly onPreviewFile?: (path: string) => void; @@ -98,10 +97,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { {node.kind === "directory" && props.expanded && props.loading ? ( - ) : node.kind === "directory" && props.loaded ? ( - - {node.children.length} - ) : null} ); @@ -115,7 +110,6 @@ export function FileTreeBrowser(props: { readonly searchQuery: string; readonly searchTruncated: boolean; readonly selectedPath: string | null; - readonly loadedDirectories: ReadonlySet; readonly loadingDirectories: ReadonlySet; readonly onLoadDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; @@ -128,14 +122,12 @@ export function FileTreeBrowser(props: { readonly selectedPathAtPress: string | null; } | null>(null); const insets = useSafeAreaInsets(); - // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the - // observed adjustedContentInset bottom (~102) seen in the native trace. - const headerInset = Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT : 0; + const columnMetrics = useNativeColumnLayoutMetrics(); + const headerInset = Platform.OS === "ios" ? (columnMetrics?.safeArea.top ?? insets.top) : 0; const { onLoadDirectory, onPreviewFile, onSelectFile, - loadedDirectories, loadingDirectories, selectedPath: controlledSelectedPath, } = props; @@ -226,7 +218,6 @@ export function FileTreeBrowser(props: { item={item} selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} - loaded={loadedDirectories.has(item.node.path)} loading={loadingDirectories.has(item.node.path)} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} @@ -237,7 +228,6 @@ export function FileTreeBrowser(props: { expandedPaths, handleSelectFile, onPreviewFile, - loadedDirectories, loadingDirectories, selectedPath, toggleDirectory, @@ -245,8 +235,8 @@ export function FileTreeBrowser(props: { ); const extraData = useMemo( - () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }), - [expandedPaths, loadedDirectories, loadingDirectories, selectedPath], + () => ({ expandedPaths, loadingDirectories, selectedPath }), + [expandedPaths, loadingDirectories, selectedPath], ); // UIKit owns the header inset on every supported iOS version. Keep the @@ -259,9 +249,7 @@ export function FileTreeBrowser(props: { data={visibleNodes} keyExtractor={(item) => item.node.path} contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} - scrollIndicatorInsets={ - Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined - } + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" estimatedItemSize={42} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 42b7dbf282d4..928da8b0f543 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -11,9 +11,10 @@ import { useWindowDimensions, View, } from "react-native"; - +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { type NativeReviewDiffViewProps, resolveNativeReviewDiffView, @@ -39,7 +40,11 @@ import { sourceHighlightAtom } from "./sourceHighlightingState"; interface SourceFileSurfaceProps { readonly contents: string; readonly path: string; + /** The enclosing sheet or card already reserves its own chrome and safe area. */ + readonly embedded?: boolean; readonly initialLine?: number | null; + /** Override when a fixed notice already reserves the native header. */ + readonly headerInsetTop?: number; /** Keep the entire document in one native text-selection scope. */ readonly selectable?: boolean; /** Enables native pull-to-refresh on the source surface. */ @@ -184,6 +189,9 @@ function NativeSourceFileSurface( }, ) { const { NativeView, onRefresh } = props; + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + const safeArea = columnMetrics?.safeArea ?? insets; const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); const { themeAppearance, themeId } = useAppearancePreferences(); const appTheme = useUniwindTheme(); @@ -214,6 +222,8 @@ function NativeSourceFileSurface( appearanceScheme={themeAppearance} contentResetKey={props.path} contentWidth={contentWidth} + contentInsetTop={props.embedded ? 0 : (props.headerInsetTop ?? safeArea.top)} + contentInsetBottom={props.embedded ? 0 : safeArea.bottom + 8} initialRowIndex={targetIndex ?? -1} rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight} rowsJson={rowsJson} @@ -233,6 +243,7 @@ function NativeSourceFileSurface( } function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { + const usesAutomaticInsets = Platform.OS === "ios" && !props.embedded; const foreground = useUniwindTheme()["--color-foreground"]; const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); @@ -323,6 +334,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const list = ( String(index)} @@ -354,6 +367,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { - ) : ( - - ); + const surface = + NativeView && !codeWordBreak && !props.selectable ? ( + + ) : ( + + ); + return surface; } diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 6c8cac046bb2..ca9adeaea165 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -48,6 +48,7 @@ import { FileTreeBrowser } from "./FileTreeBrowser"; import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { ScreenHeader } from "../../components/ScreenHeader"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; @@ -229,6 +230,10 @@ function FileContent(props: { }) { // Reopening a mutable host file must not reuse a poster from an earlier visit. const thumbnailInstanceId = useId(); + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + const reservedHeaderInset = + Platform.OS === "ios" && props.truncated ? (columnMetrics?.safeArea.top ?? insets.top) : 0; const isMarkdown = isMarkdownPreviewFile(props.relativePath); const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); @@ -300,7 +305,7 @@ function FileContent(props: { } return ( - + {props.truncated ? ( Preview limited to the first 1 MB of a truncated file. @@ -320,6 +325,7 @@ function FileContent(props: { contents={props.fileContents} path={props.relativePath} initialLine={props.initialLine} + headerInsetTop={props.truncated ? 0 : undefined} onRefresh={props.onRefresh} /> )} @@ -561,7 +567,6 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { Date: Wed, 7 Oct 2026 22:45:07 -0700 Subject: [PATCH 017/273] fix(web): PR code toolbar no longer overlaps in narrow panels (#15561) --- .../pullRequest/PullRequestCodeTab.tsx | 178 +++++++++++------- 1 file changed, 115 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d531259e5781..7c631825dc45 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -19,6 +19,7 @@ import { MessageSquareOffIcon, PilcrowIcon, Rows3Icon, + Settings2Icon, TextWrapIcon, TriangleAlertIcon, } from "lucide-react"; @@ -69,10 +70,12 @@ import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/menu"; import { toastManager } from "../ui/toast"; @@ -1075,8 +1078,13 @@ function PullRequestCodeTab({ } }, [commit, onSelectedCommitChange, selectedCommit]); const scopeLabel = selectedCommit ? selectedCommit.messageHeadline : "All commits"; + const changeIgnoreWhitespace = (next: boolean) => { + setIgnoreWhitespace(next); + setDraft(null); + setSelectedLines(null); + }; const toolbar = ( -
+
{/* A host that reports no commits has nothing to scope by, and a dropdown whose only entry is the scope already showing is a control that does nothing. */} @@ -1133,7 +1141,7 @@ function PullRequestCodeTab({ ) : null} {/* One count, and the caveats as icons that carry their own words. Spelled out they competed for a strip this narrow and every one of them truncated to nothing. */} - + {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} @@ -1224,30 +1232,113 @@ function PullRequestCodeTab({
- - + + } + className="@lg:hidden" + aria-label="Diff options" + /> + } + > + + + Diff options + + + changeIgnoreWhitespace(checked)} + > + Hide whitespace changes + + setWordWrap(checked)} + > + Wrap lines + + + { + if (value === "stacked" || value === "split") { + updateClientSettings({ diffLayout: value }); } - variant="ghost" - size="sm" - pressed={ignoreWhitespace} - onPressedChange={(pressed) => { - setIgnoreWhitespace(Boolean(pressed)); - setDraft(null); - setSelectedLines(null); - }} - /> - } + }} + > + + Stacked + + + Split + + + + +
+ + changeIgnoreWhitespace(Boolean(pressed))} + /> + } + > + + + + {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + + + { + const next = value[0]; + if (next === "stacked" || next === "split") { + updateClientSettings({ diffLayout: next }); + } + }} > - - - - {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} - - + + + + + + + + + { + setWordWrap(Boolean(pressed)); + }} + /> + } + > + + + + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} + + +
{fileKeys.length > 0 ? ( ) : null} - { - const next = value[0]; - if (next === "stacked" || next === "split") { - updateClientSettings({ diffLayout: next }); - } - }} - > - - - - - - - - - { - setWordWrap(Boolean(pressed)); - }} - /> - } - > - - - - {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} - - {fileKeys.length > 0 ? ( Date: Wed, 7 Oct 2026 22:45:24 -0700 Subject: [PATCH 018/273] fix(web): PR commit menu no longer stretches across the window (#15560) --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 7c631825dc45..1339b46196b8 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1098,7 +1098,7 @@ function PullRequestCodeTab({ {scopeLabel} - + onSelectedCommitChange(value === "all" ? null : value)} From 647d8affa78c848c2a55c3e00ed14af9a9eca58e Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:46:26 -0700 Subject: [PATCH 019/273] fix(web): command palette scrollbar no longer clipped at the top (#17035) --- apps/web/src/components/CommandPaletteResults.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index b909a0c97e44..8a00d8f435c2 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -147,7 +147,7 @@ export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualize onScroll={updateScrollFade} contentContainerClassName="px-2" className={cn( - "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2", + "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2 [&::-webkit-scrollbar-track]:my-2", getVirtualizedScrollFadeClassName(scrollFade), )} /> From 85a41391e9625d760f5bb2294834c38f42bbf5b8 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:46:39 -0700 Subject: [PATCH 020/273] fix(web): Usage breadcrumb stays centered on small viewports (#15552) --- apps/web/src/components/usage/UsagePage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 0a525054915f..a7a1be01188d 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -328,8 +328,8 @@ export function UsagePage() { ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; const topbarContent = ( -
- +
+

Usage

@@ -398,7 +398,7 @@ export function UsagePage() {
-
+
{ element?.focus(); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8a5db463884e..d668fa7871b2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1763,7 +1763,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Unpin thread" onClick={handleUnpinClick} - className="group/unpin inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" + className="group/unpin inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" /> } > @@ -1876,7 +1876,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Dismiss Woke notification" onClick={handleAcknowledgeWokeClick} - className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-warning-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring" + className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-warning-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" > Woke @@ -2040,7 +2040,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { aria-label="Dismiss Woke notification" onClick={handleAcknowledgeWokeClick} className={cn( - "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", + "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", topStatus.className, )} > diff --git a/apps/web/src/components/auth/ConnectAgentSurface.tsx b/apps/web/src/components/auth/ConnectAgentSurface.tsx index 12d620a0c7e5..b3ad9cd10526 100644 --- a/apps/web/src/components/auth/ConnectAgentSurface.tsx +++ b/apps/web/src/components/auth/ConnectAgentSurface.tsx @@ -311,9 +311,9 @@ function AccessOption({ value={access} className={cn( "flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]", - "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", selected - ? "border-primary bg-background ring-2 ring-primary/25 dark:border-transparent dark:bg-primary/10 dark:ring-1 dark:ring-primary/30" + ? "border-primary bg-background ring-2 ring-inset ring-primary/25 dark:border-transparent dark:bg-primary/10 dark:ring-1 dark:ring-primary/30" : "border-border bg-background hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent", )} > diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index a1cb0dac252a..779e4bc13422 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -178,7 +178,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { type="button" data-scroll-anchor-ignore aria-expanded={isExpanded} - className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" + className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" style={{ paddingLeft: `${leftPadding}px` }} onClick={() => toggleDirectory(node.path)} > @@ -213,7 +213,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8d2b445a22ce..3e95f86de902 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1817,7 +1817,7 @@ function TimelineMinimap({ /> diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 9bf65102bf70..dd8cd0dab03f 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -203,7 +203,7 @@ export function MediaVideoPlayer({ type="button" aria-label={label ? `Play ${label}` : "Play video"} onClick={onOpen} - className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" > diff --git a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx index 39af63a90616..afba72c805bd 100644 --- a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx +++ b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx @@ -44,7 +44,7 @@ export function PreviewRecentUrlCard({ threadRef, entry, onOpen, onRemove }: Pro type="button" aria-label={`Remove ${label} from history`} onClick={onRemove} - className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100" + className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring group-hover:opacity-100" > diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 19a246cf4be4..6db7e296337b 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -369,7 +369,7 @@ export function ProjectScriptEditorDialog({ type="button" className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${ isSelected - ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30" + ? "border-primary/70 bg-primary/10 ring-inset dark:ring-1 dark:ring-primary/30" : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]" }`} onClick={() => { diff --git a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx index bcfb3b180113..48f71d139675 100644 --- a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx @@ -32,7 +32,7 @@ export function PullRequestCopyableCode({ diff --git a/apps/web/src/components/settings/FoldedSettingsSection.tsx b/apps/web/src/components/settings/FoldedSettingsSection.tsx index cb6f09af1001..cf4e91fcf534 100644 --- a/apps/web/src/components/settings/FoldedSettingsSection.tsx +++ b/apps/web/src/components/settings/FoldedSettingsSection.tsx @@ -46,7 +46,7 @@ export function FoldedSettingsSection({ className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4" >

- + {title} }>
- + @@ -818,7 +818,7 @@ function KeybindingKeyControl({ onClick={() => setDraft({ isRecording: true })} aria-label={`Edit shortcut for ${commandLabel(row.command)}: ${formatShortcutLabel(row.binding.shortcut)}`} className={cn( - "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/24", + "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-ring/24", pillClassName, )} > diff --git a/apps/web/src/components/settings/PanelAnimationsPreview.tsx b/apps/web/src/components/settings/PanelAnimationsPreview.tsx index 34178f9b54c7..9bc2478923b1 100644 --- a/apps/web/src/components/settings/PanelAnimationsPreview.tsx +++ b/apps/web/src/components/settings/PanelAnimationsPreview.tsx @@ -11,7 +11,7 @@ export function PanelAnimationsPreview({ durationMs }: { durationMs: number }) { + } + /> + + {summaries.some(({ report }) => report !== null) && ( + + report + ? [ + `${connectedEnvironments.length > 1 ? `${environment.label}: ` : ""}${report.counts.removed} removed, ${report.counts.kept} kept${report.counts.failed > 0 ? `, ${report.counts.failed} failed` : ""}`, + ] + : [], + ) + .join(" · ")} + > + {summaries.map( + ({ environment, report }) => + report && ( + 1 ? environment.label : undefined + } + /> + ), + )} + + )} + + ); +} + export function StorageSettingsPanel() { const { scope, connectedEnvironments, targets, target } = useSettingsScope(); const scopedSettings = useScopedSettings(); @@ -312,6 +564,49 @@ export function StorageSettingsPanel() { /> } /> + {connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.storageCleanupRun === true, + ) && ( + { + if ( + next === "any-local-files" || + next === "uncommitted-changes" || + next === "tracked-changes" + ) + updateWorktree({ worktreeKeepWhen: next }); + }} + > + + + {ruleStatus("worktreeKeepWhen") + ? "Mixed" + : KEEP_WHEN_LABELS[settings.worktreeKeepWhen]} + + + + Any local files + Uncommitted changes + Edited tracked files + + + } + /> + )} )} @@ -346,6 +641,7 @@ export function StorageSettingsPanel() { /> )} + {!isProjectScope && } ); } diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 800ff48360a9..a47a4bf00241 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -160,6 +160,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -183,12 +184,14 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, { worktreeAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, ]); }); @@ -295,6 +298,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -315,6 +319,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -328,6 +333,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 853886d81fcd..aff52adeb155 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -134,6 +134,23 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "storage-worktree-keep-when", + targetId: "storage-worktrees", + title: "Keep worktrees with local changes", + to: "/settings/storage", + scope: "project-defaults", + searchTerms: [ + "any local files uncommitted changes edited tracked files ignored env build output cleanup", + ], + }, + { + id: "storage-delete-now", + title: "Delete now", + to: "/settings/storage", + scope: "environment-defaults", + searchTerms: ["storage cleanup run now results report removed kept failed"], + }, { id: "storage-worktrees", title: "Worktree cleanup", diff --git a/apps/web/src/lib/formatBytes.ts b/apps/web/src/lib/formatBytes.ts new file mode 100644 index 000000000000..306302db6761 --- /dev/null +++ b/apps/web/src/lib/formatBytes.ts @@ -0,0 +1,11 @@ +export function formatBytes(value: number): string { + if (value < 1_024) return `${Math.round(value)} B`; + const units = ["KB", "MB", "GB", "TB"] as const; + let next = value; + let unitIndex = -1; + do { + next /= 1_024; + unitIndex += 1; + } while (next >= 1_024 && unitIndex < units.length - 1); + return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 598ef5cb5e2e..c92226a9ee4f 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -143,8 +143,11 @@ captures and log retention remain machine-wide. Worktrees can be removed after a chosen number of inactive days, after merging, or when they have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active -sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` -prevent removal. Branches and thread history stay; starting another turn recreates the checkout. +sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults +to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored +files such as `.env` and build output. **Any local files** also protects ignored files other than +`node_modules`. **Edited tracked files** protects only tracked edits and allows untracked and +ignored files to be deleted. Branches and thread history stay; starting another turn recreates the checkout. Merge cleanup requires a merged pull request whose commits are included in the remote default branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default branch and the worktree is still at the pull request's last commit. @@ -154,6 +157,9 @@ thread is deleted, including archived threads and worktrees left by earlier dele server waits for sessions and terminals to stop and retries skipped worktrees after restart. Existing prompts for deleting a worktree manually remain available when this policy is off. +Choose **Delete now** to run the enabled rules immediately. The latest results show which +worktrees were removed or kept and why, plus any failures. + Browser captures and rotated logs have separate retention periods. Expired capture links stop working. Current logs, message attachments, and browser profiles are kept. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 35a957e9d0f2..c11a49897b88 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -58,6 +58,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe + | typeof WS_METHODS.serverGetStorageCleanupReport | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index b9d1ebf6b738..b2c6933fabb7 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, ThreadId, EnvironmentId, @@ -58,6 +59,32 @@ const setup = Effect.gen(function* () { }); describe("command permissions", () => { + it.effect("requires the destination settings grant to run storage cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const cleanup = createCommandPermissions(runtime, WS_METHODS.serverRunStorageCleanup); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(cleanup.permissionAtom(env))).toBe(false); + expect((yield* cleanup.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(true), + scopes: [AuthSettingsWriteScope], + permissions: [AuthSettingsWriteScope], + }), + ); + expect(registry.get(cleanup.permissionAtom(env))).toBe(true); + yield* cleanup.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(cleanup.permissionAtom(other))).toBe(false); + }), + ), + ); + it.effect("uses the target grant for both availability and execution", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 91464b16a9b1..1d49fd71f70c 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -983,6 +983,16 @@ export function createServerEnvironmentAtoms( return { configValueAtom, + runStorageCleanup: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:run-storage-cleanup", + tag: WS_METHODS.serverRunStorageCleanup, + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), + storageCleanupReport: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:storage-cleanup-report", + tag: WS_METHODS.serverGetStorageCleanupReport, + idleTtlMs: 0, + }), updateStateAtom, settingsValueAtom, providersValueAtom, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 9605829e47d7..c84169e6e33f 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput } from "./git.ts"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, type AuthEnvironmentScope, } from "./auth.ts"; @@ -9,6 +10,7 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c9ac7482d4cc..2a61480a907b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server evaluates merge and inactivity settlement without a client. */ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), + storageCleanupRun: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), /** Server honors the `worktreesDirectory` setting. */ worktreesDirectory: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ed14e2e16b7c..36afee2f9efa 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -317,7 +317,12 @@ import { ProviderConsumeResetCreditResult, } from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; -import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { + StorageCleanupReport, + ServerSettings, + ServerSettingsError, + ServerSettingsPatch, +} from "./settings.ts"; import { ScheduledTaskDeleteInput, ScheduledTaskDeleteResult, @@ -468,6 +473,8 @@ export const WS_METHODS = { serverCommitDesktopUpdate: "server.commitDesktopUpdate", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", + serverRunStorageCleanup: "server.runStorageCleanup", + serverGetStorageCleanupReport: "server.getStorageCleanupReport", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -730,6 +737,18 @@ const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUp error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); +const WsServerRunStorageCleanupRpc = Rpc.make(WS_METHODS.serverRunStorageCleanup, { + payload: Schema.Struct({}), + success: StorageCleanupReport, + error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), +}); +const WsServerGetStorageCleanupReportRpc = Rpc.make(WS_METHODS.serverGetStorageCleanupReport, { + payload: Schema.Struct({}), + success: Schema.NullOr(StorageCleanupReport), + stream: true, + error: EnvironmentAuthorizationError, +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1833,6 +1852,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerCommitDesktopUpdateRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, + WsServerRunStorageCleanupRpc, + WsServerGetStorageCleanupReportRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 58e8fa5d9658..b5d39323a23d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -57,6 +57,7 @@ describe("storage cleanup settings", () => { worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: null, }); @@ -86,6 +87,29 @@ describe("storage cleanup settings", () => { ).toThrow(); }); + it("decodes older custom rules with the default local-file policy", () => { + const settings = decodeServerSettings({ + worktreeCleanup: { + mode: "custom", + rules: { + worktreeAfterDays: 8, + worktreeOnMerge: false, + worktreeOnDelete: false, + worktreeUnchanged: false, + }, + }, + }); + expect(settings.worktreeCleanup).toMatchObject({ + rules: { worktreeKeepWhen: "uncommitted-changes" }, + }); + expect( + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }), + ).toEqual({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }); + expect(() => + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "unknown" } }), + ).toThrow(); + }); + it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => { expect(() => decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0e8027b9f5fd..0038ede86c2d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -13,6 +13,7 @@ import { ForwardCompatibleOptional, OmittedWhenNull, ProjectId, + ThreadId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; @@ -941,7 +942,17 @@ const StorageRetentionDays = Schema.NullOr( Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), ); +export const WorktreeKeepWhen = Schema.Literals([ + "any-local-files", + "uncommitted-changes", + "tracked-changes", +]); +export type WorktreeKeepWhen = typeof WorktreeKeepWhen.Type; + export const WorktreeCleanupRules = Schema.Struct({ + worktreeKeepWhen: WorktreeKeepWhen.pipe( + Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), + ), worktreeAfterDays: StorageRetentionDays, worktreeOnMerge: Schema.Boolean, worktreeOnDelete: Schema.Boolean, @@ -1034,7 +1045,32 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet { worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited); const custom = applyServerSettingsPatch(off, { @@ -370,6 +371,7 @@ describe("resolveWorktreeCleanup", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect( resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null) diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index f99dacbbb69d..af9a7112c3ea 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -220,12 +220,24 @@ export function resolveWorktreeCleanup( if (policy?.mode === "custom") return policy.rules; if (policy?.mode === "off") return { + worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen, worktreeAfterDays: null, worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, }; - const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } = - settings.storageCleanup; - return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged }; + const { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + } = settings.storageCleanup; + return { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + }; } diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index f7305812e74f..dbc2704171f9 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -37,6 +37,7 @@ describe("serverSettings helpers", () => { worktreeOnMerge: true, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: 30, }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 99b0f2c336c1..909eeb0a35ce 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -324,6 +324,7 @@ export function applyServerSettingsPatch( ? { mode: "custom" as const, rules: { + worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, worktreeAfterDays: next.storageCleanup.worktreeAfterDays, worktreeOnMerge: next.storageCleanup.worktreeOnMerge, worktreeOnDelete: next.storageCleanup.worktreeOnDelete, From e858ddb65668d54be5de3996f30c0bd5c3149657 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sat, 10 Oct 2026 03:09:23 +0100 Subject: [PATCH 178/273] test(usage): usage service tests keep their state directory until cache writes land (#17636) --- apps/server/src/usage/UsageService.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 393437fcc37b..e28e926d3f89 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -132,7 +132,9 @@ const layerService = (input: { Layer.provideMerge(ProviderHostLive.layer), Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: input.prefix })), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), NodePath.join(input.home, input.prefix)), + ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), @@ -1203,8 +1205,7 @@ describe("UsageService", () => { return text; }); - const restarted = yield* UsageService.make; - const summary = yield* restarted.readSummary(WINDOW); + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); // The live rollout re-parses at the ultrafast rate (10 x 6); the // deleted one keeps its saved v4 usage at the standard rate (20 x 1). assert.strictEqual(totalOutputTokens(summary), 30); @@ -1217,9 +1218,6 @@ describe("UsageService", () => { yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, ); - // The migrated cache is written in the background; let it land before - // the layer removes the state directory under it. - yield* restarted.awaitPersisted; }).pipe( Effect.provide( layerService({ From f3a69c70c6a5bc624e1b5eb80901d8b7c901ad6f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 9 Oct 2026 19:09:46 -0700 Subject: [PATCH 179/273] fix(checkpoint): pulls and rebases no longer flood a turn's changed files (#17161) Co-authored-by: Dylan Zahn Co-authored-by: Claude Opus 5.5 --- .../checkpointing/CheckpointDiffQuery.test.ts | 30 ++++ .../src/checkpointing/CheckpointDiffQuery.ts | 68 +++++++-- .../src/checkpointing/CheckpointStore.test.ts | 132 +++++++++++++++++- .../src/checkpointing/CheckpointStore.ts | 29 ++++ apps/server/src/checkpointing/Diffs.test.ts | 10 +- apps/server/src/checkpointing/Diffs.ts | 20 +++ .../CheckpointCaptureService.test.ts | 1 + .../CheckpointScopeOwnership.test.ts | 1 + .../src/orchestration-v2/CheckpointService.ts | 54 ++++--- apps/server/src/vcs/GitVcsDriver.ts | 64 ++++++++- apps/server/src/vcs/VcsDriver.ts | 11 ++ docs/user/source-control.md | 7 + 12 files changed, 390 insertions(+), 37 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index b7cf965f57ca..bdb3e02785d8 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -48,6 +48,7 @@ function makeProjection(): ProjectionCheckpointContext { function layerFor(input: { readonly projection: Effect.Effect; readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"]; + readonly authoredPaths?: ReadonlySet; }) { return CheckpointDiffQuery.layer.pipe( Layer.provide( @@ -57,6 +58,7 @@ function layerFor(input: { }), Layer.mock(CheckpointStore.CheckpointStore)({ diffCheckpoints: input.diffCheckpoints ?? (() => Effect.succeed("diff")), + listAuthoredPaths: () => Effect.succeed(input.authoredPaths ?? null), }), ), ), @@ -93,6 +95,34 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => { }).pipe(Effect.provide(layer)); }); +it.effect("leaves Git imports out of large turn diffs", () => { + const ownPaths = Array.from({ length: 1_000 }, (_, index) => `src/module-${index}/file.ts`); + const diffCheckpoints = vi.fn((input: CheckpointStore.DiffCheckpointsInput) => + Effect.succeed( + input.format === "numstat" + ? [...ownPaths, "upstream.ts"].map((path) => `1\t0\t${path}\0`).join("") + : (input.filePaths ?? ["all"]).join("\n"), + ), + ); + const layer = layerFor({ + projection: Effect.succeed(makeProjection()), + diffCheckpoints, + authoredPaths: new Set(ownPaths), + }); + + return Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + yield* query.getFullThreadDiff({ threadId, toTurnCount: 2 }); + + const patchPaths = diffCheckpoints.mock.calls + .map(([call]) => call) + .filter((call) => call.format !== "numstat") + .map((call) => call.filePaths ?? ["all"]); + assert.isAbove(patchPaths.length, 1); + assert.sameMembers(patchPaths.flat(), ownPaths); + }).pipe(Effect.provide(layer)); +}); + it.effect("preserves the typed missing-thread error contract", () => { const layer = layerFor({ projection: Effect.fail(new OrchestratorProjectionError({ threadId })), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 37414f35bfb6..37ba07ca12c3 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -31,6 +31,31 @@ import { type CheckpointServiceError, } from "./Errors.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; +import { isGitImport, parseTurnDiffFilesFromNumstat, type TurnDiffFileSummary } from "./Diffs.ts"; + +// Windows limits a command line to 32,767 characters. Leave room for the rest of git's arguments. +const MAX_PATHSPEC_CHARS = 24_000; + +/** Splits files into pathspec lists that each fit on one git command line. Renames keep both paths together. */ +function batchFilePaths(files: ReadonlyArray) { + const batches: string[][] = []; + let batch: string[] = []; + let length = 0; + for (const file of files) { + const paths = file.previousPath === undefined ? [file.path] : [file.previousPath, file.path]; + // Each pathspec also carries a `:(top,literal)` prefix and a separator. + const size = paths.reduce((total, path) => total + path.length + 16, 0); + if (batch.length > 0 && length + size > MAX_PATHSPEC_CHARS) { + batches.push(batch); + batch = []; + length = 0; + } + batch.push(...paths); + length += size; + } + if (batch.length > 0) batches.push(batch); + return batches; +} /** Service tag for checkpoint diff queries. */ export class CheckpointDiffQuery extends Context.Service< @@ -182,15 +207,40 @@ export const make = Effect.gen(function* () { }); } - const diff = yield* checkpointStore - .diffCheckpoints({ - cwd: toScope.cwd, - fromCheckpointRef, - toCheckpointRef: toCheckpoint.ref, - fallbackFromToHead: false, - ignoreWhitespace, - }) - .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); + const comparison = { + cwd: toScope.cwd, + fromCheckpointRef, + toCheckpointRef: toCheckpoint.ref, + fallbackFromToHead: false, + ignoreWhitespace, + }; + // Leave out files that a pull, merge, or rebase brought in, matching the turn's file summary. + // Filtering is optional: when it cannot run, the complete diff still loads. + const authoredPaths = yield* checkpointStore + .listAuthoredPaths(comparison) + .pipe(Effect.orElseSucceed(() => null)); + const files = + authoredPaths === null + ? [] + : yield* checkpointStore.diffCheckpoints({ ...comparison, format: "numstat" }).pipe( + Effect.map(parseTurnDiffFilesFromNumstat), + Effect.orElseSucceed(() => []), + ); + const retainedFiles = files.filter((file) => !isGitImport(file, authoredPaths)); + // Select retained paths before generating a patch, so imported bulk cannot exhaust its output limit. + const diff = + retainedFiles.length === files.length + ? yield* checkpointStore + .diffCheckpoints(comparison) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")) + : (yield* Effect.forEach( + batchFilePaths(retainedFiles), + (filePaths) => + checkpointStore + .diffCheckpoints({ ...comparison, filePaths }) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")), + { concurrency: 4 }, + )).join(""); const turnDiff = buildTurnDiffResult(input, diff); if (!isTurnDiffResult(turnDiff)) { diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 2846b86e1b88..b345535fade7 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -57,15 +57,22 @@ function writeTextFile( function git( cwd: string, args: ReadonlyArray, + options: { readonly committedAt?: string; readonly allowNonZeroExit?: boolean } = {}, ): Effect.Effect { return Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const result = yield* process.run({ + const vcsProcess = yield* VcsProcess.VcsProcess; + const result = yield* vcsProcess.run({ operation: "CheckpointStore.test.git", command: "git", cwd, args, timeoutMs: 10_000, + ...(options.committedAt === undefined + ? {} + : { env: { ...process.env, GIT_COMMITTER_DATE: options.committedAt } }), + ...(options.allowNonZeroExit === undefined + ? {} + : { allowNonZeroExit: options.allowNonZeroExit }), }); return result.stdout.trim(); }); @@ -371,12 +378,12 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ); const expectedFiles = [ { path: "binary.bin", additions: 0, deletions: 0 }, - { path: "copied.txt", additions: 0, deletions: 0 }, + { path: "copied.txt", previousPath: "copy-source.txt", additions: 0, deletions: 0 }, { path: "copy-source.txt", additions: 1, deletions: 0 }, { path: "deleted.txt", additions: 0, deletions: 1 }, { path: "empty.txt", additions: 0, deletions: 0 }, { path: addedPath, additions: 2, deletions: 0 }, - { path: renamedPath, additions: 1, deletions: 1 }, + { path: renamedPath, previousPath: "rename-old.txt", additions: 1, deletions: 1 }, ].toSorted((left, right) => left.path.localeCompare(right.path)); expect(firstSummary).toEqual(expectedFiles); @@ -441,5 +448,122 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ]); }), ); + it.effect("limits a diff to repository paths from a subdirectory workspace", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const fileSystem = yield* FileSystem.FileSystem; + const cwd = NodePath.join(tmp, "app"); + yield* fileSystem.makeDirectory(cwd); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-file-paths-subdirectory"); + const refs = { + cwd, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.fromCheckpointRef }); + yield* writeTextFile(NodePath.join(cwd, "kept.txt"), "kept\n"); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# hidden\n"); + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.toCheckpointRef }); + + const patch = yield* checkpointStore.diffCheckpoints({ + ...refs, + ignoreWhitespace: false, + filePaths: ["app/kept.txt"], + }); + expect(patch).toContain("+kept"); + expect(patch).not.toContain("hidden"); + }), + ); + }); + + describe("listAuthoredPaths", () => { + it.effect("leaves out paths that only a merged upstream changed", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + const commitBeforeTurn = (file: string, contents: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(tmp, file), contents); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); + }); + + yield* git(tmp, ["checkout", "-b", "upstream"]); + yield* commitBeforeTurn("upstream.txt", "upstream\n"); + yield* commitBeforeTurn("shared.txt", "upstream\n"); + yield* git(tmp, ["checkout", "-"]); + yield* commitBeforeTurn("shared.txt", "ours\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + expect(yield* checkpointStore.listAuthoredPaths(refs)).toBeNull(); + + // The turn merges upstream, fixes the conflict by taking upstream, and edits one more file. + yield* git(tmp, ["merge", "upstream", "--no-edit"], { allowNonZeroExit: true }); + yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "upstream\n"); + yield* git(tmp, ["commit", "-am", "merge upstream"]); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# edited\n"); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + const numstat = yield* checkpointStore.diffCheckpoints({ + ...refs, + ignoreWhitespace: false, + format: "numstat", + }); + expect(parseTurnDiffFilesFromNumstat(numstat).map((file) => file.path)).toEqual([ + "README.md", + "shared.txt", + "upstream.txt", + ]); + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual( + new Set(["README.md", "shared.txt"]), + ); + }), + ); + + it.effect("leaves out paths that only a rebase onto upstream brought in", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths-rebase"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + const commitBeforeTurn = (file: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(tmp, file), `${file}\n`); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); + }); + + yield* git(tmp, ["checkout", "-b", "upstream"]); + yield* commitBeforeTurn("upstream.txt"); + yield* git(tmp, ["checkout", "-"]); + yield* commitBeforeTurn("feature.txt"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + yield* git(tmp, ["rebase", "upstream"]); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(new Set(["feature.txt"])); + }), + ); }); }); diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index 0c9e60d76a65..78844ec70dfe 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -40,6 +40,14 @@ export interface DiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Limits the diff to these exact paths. An empty list yields an empty diff. */ + readonly filePaths?: ReadonlyArray; +} + +export interface ListAuthoredPathsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; } export interface DeleteCheckpointRefsInput { @@ -87,6 +95,19 @@ export class CheckpointStore extends Context.Service< input: DiffCheckpointsInput, ) => Effect.Effect; + /** + * List paths changed by work done after the "from" checkpoint: uncommitted + * edits at either checkpoint, commits made after "from", and commits that + * left HEAD. Commits a pull, merge, or rebase brought in are older than + * "from", so their paths are not listed. + * + * Returns null when HEAD did not move or a checkpoint does not record HEAD. + * Then every changed path belongs to the turn. + */ + readonly listAuthoredPaths: ( + input: ListAuthoredPathsInput, + ) => Effect.Effect | null, CheckpointStoreError>; + /** * Delete the provided checkpoint refs. * @@ -150,6 +171,13 @@ export const make = Effect.gen(function* () { return yield* checkpoints.diffCheckpoints(input); }); + const listAuthoredPaths: CheckpointStore["Service"]["listAuthoredPaths"] = Effect.fn( + "listAuthoredPaths", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.listAuthoredPaths", input.cwd); + return yield* checkpoints.listAuthoredPaths(input); + }); + const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn( "deleteCheckpointRefs", )(function* (input) { @@ -166,6 +194,7 @@ export const make = Effect.gen(function* () { hasCheckpointRef, restoreCheckpoint, diffCheckpoints, + listAuthoredPaths, deleteCheckpointRefs, }); }); diff --git a/apps/server/src/checkpointing/Diffs.test.ts b/apps/server/src/checkpointing/Diffs.test.ts index 6b7f1a875690..441643fbda63 100644 --- a/apps/server/src/checkpointing/Diffs.test.ts +++ b/apps/server/src/checkpointing/Diffs.test.ts @@ -15,7 +15,7 @@ describe("parseTurnDiffFilesFromNumstat", () => { ]); }); - it("uses destination paths for renames and copies", () => { + it("preserves both paths for renames and copies", () => { const numstat = [ "0\t0\t", "src/old.ts", @@ -29,8 +29,8 @@ describe("parseTurnDiffFilesFromNumstat", () => { expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ { path: "other.ts", additions: 1, deletions: 0 }, - { path: "src/copied.ts", additions: 2, deletions: 1 }, - { path: "src/new.ts", additions: 0, deletions: 0 }, + { path: "src/copied.ts", previousPath: "src/source.ts", additions: 2, deletions: 1 }, + { path: "src/new.ts", previousPath: "src/old.ts", additions: 0, deletions: 0 }, ]); }); @@ -46,7 +46,9 @@ describe("parseTurnDiffFilesFromNumstat", () => { const path = " café\tline\r\nname.txt "; const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`; - expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([{ path, additions: 3, deletions: 2 }]); + expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ + { path, previousPath: "old\tname\n.txt", additions: 3, deletions: 2 }, + ]); expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([ { path, additions: 1, deletions: 0 }, ]); diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts index b7951009118e..24aa3b6879e9 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -1,5 +1,6 @@ export interface TurnDiffFileSummary { readonly path: string; + readonly previousPath?: string; readonly additions: number; readonly deletions: number; } @@ -15,8 +16,10 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray left.path.localeCompare(right.path)); } + +/** + * True when Git brought this file in and no work in the range touched it. + * `authoredPaths` comes from `CheckpointStore.listAuthoredPaths`; null means nothing was imported. + * A rename stays visible when either side was authored, because the summary shows it as one file. + */ +export function isGitImport( + file: TurnDiffFileSummary, + authoredPaths: ReadonlySet | null, +): boolean { + return ( + authoredPaths !== null && + !authoredPaths.has(file.path) && + (file.previousPath === undefined || !authoredPaths.has(file.previousPath)) + ); +} diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index bb7619f6a8ab..9b24161b54e3 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -277,6 +277,7 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { IdAllocator.layer, NodeCrypto.layer, Layer.mock(CheckpointStore.CheckpointStore)({ + listAuthoredPaths: () => Effect.succeed(null), isGitRepository: () => Effect.succeed(true), captureCheckpoint: () => Effect.void, hasCheckpointRef: () => diff --git a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts index b29eb33b69a2..33a7490a0245 100644 --- a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts @@ -219,6 +219,7 @@ it.effect("resolves the thread baseline after a second root run replaces scope o getCheckpointContext: () => Effect.succeed(context), }), Layer.mock(CheckpointStore.CheckpointStore)({ + listAuthoredPaths: () => Effect.succeed(null), diffCheckpoints: (input) => { assert.equal(input.cwd, "/prepared-repo"); assert.equal(input.fromCheckpointRef, checkpointRefs[0]); diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 913e53658e2c..ee3316311eac 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -19,7 +19,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; +import { isGitImport, parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -440,33 +440,49 @@ export const layer: Layer.Layer< }).pipe(Effect.as(false)), ), ); + const refs = { + cwd: input.scope.cwd, + fromCheckpointRef: previousCheckpointRef, + toCheckpointRef: checkpointRef, + }; + // A pull or rebase can change thousands of files the turn did not write. + // Keep only the files the turn's own work touched. const files = previousExists - ? yield* checkpointStore - .diffCheckpoints({ - cwd: input.scope.cwd, - fromCheckpointRef: previousCheckpointRef, - toCheckpointRef: checkpointRef, + ? yield* Effect.all([ + checkpointStore.diffCheckpoints({ + ...refs, fallbackFromToHead: false, ignoreWhitespace: false, format: "numstat", - }) - .pipe( - Effect.map((diff) => - parseTurnDiffFilesFromNumstat(diff).map((file) => ({ - path: file.path, - kind: "modified", - additions: file.additions, - deletions: file.deletions, - })), - ), + }), + checkpointStore.listAuthoredPaths(refs).pipe( Effect.catch((cause) => - Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + Effect.logWarning("orchestration V2 checkpoint authored paths failed", { scopeId: input.scope.id, checkpointRef, cause: String(cause), - }).pipe(Effect.as([])), + }).pipe(Effect.as(null)), ), - ) + ), + ]).pipe( + Effect.map(([diff, authoredPaths]) => + parseTurnDiffFilesFromNumstat(diff) + .filter((file) => !isGitImport(file, authoredPaths)) + .map((file) => ({ + path: file.path, + kind: "modified", + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + scopeId: input.scope.id, + checkpointRef, + cause: String(cause), + }).pipe(Effect.as([])), + ), + ) : []; return makeCheckpoint({ diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6513d037c8dc..599269791edf 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1049,10 +1049,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } const message = `t3 checkpoint ref=${input.checkpointRef}`; + // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints. const commitTreeResult = yield* execute({ operation, cwd: input.cwd, - args: [...durableWrite, "commit-tree", treeOid, "-m", message], + args: [ + ...durableWrite, + "commit-tree", + treeOid, + ...(headExists ? ["-p", "HEAD"] : []), + "-m", + message, + ], env: commitEnv, }); const commitOid = commitTreeResult.stdout.trim(); @@ -1157,6 +1165,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) { const operation = "GitVcsDriver.checkpoints.diffCheckpoints"; + if (input.filePaths?.length === 0) return ""; yield* Effect.annotateCurrentSpan({ "checkpoint.cwd": input.cwd, "checkpoint.from_ref": input.fromCheckpointRef, @@ -1202,6 +1211,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, `${input.toCheckpointRef}^{commit}`, + // Paths are repository-relative, while cwd can be a subdirectory. + ...(input.filePaths + ? ["--", ...input.filePaths.map((file) => `:(top,literal)${file}`)] + : []), ], allowNonZeroExit: true, maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, @@ -1221,6 +1234,55 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return result.stdout; }), + listAuthoredPaths: Effect.fn("GitVcsDriver.checkpoints.listAuthoredPaths")(function* (input) { + const operation = "GitVcsDriver.checkpoints.listAuthoredPaths"; + const heads = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "log", + "--no-walk=unsorted", + "--format=%P %ct", + `${input.fromCheckpointRef}^{commit}`, + `${input.toCheckpointRef}^{commit}`, + ], + }); + // Each line is " ". The parent is the HEAD at capture. + const [fromLine = "", toLine = ""] = heads.stdout.trimEnd().split("\n"); + const [startHead = "", capturedAt = ""] = fromLine.split(" "); + const [endHead = ""] = toLine.split(" "); + if (startHead === "" || endHead === "" || startHead === endHead) { + return null; + } + + const listPaths = (args: ReadonlyArray) => + execute({ + operation, + cwd: input.cwd, + args: [...args, "--name-only", "-z", "--no-renames", "--no-ext-diff"], + maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + outputMode: "error", + }).pipe(Effect.map((result) => result.stdout.split("\0"))); + // Remerge diff lists a merge's paths only where the result differs from Git's + // automatic merge, so a merge keeps its conflict fixes and drops clean upstream changes. + const pathLists = yield* Effect.all( + [ + listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]), + listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]), + listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]), + listPaths([ + "log", + "--format=", + "--diff-merges=remerge", + `--since=@${capturedAt}`, + `${startHead}..${endHead}`, + ]), + ], + { concurrency: "unbounded" }, + ); + return new Set(pathLists.flat().filter((path) => path.length > 0)); + }), + deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( function* (input) { yield* Effect.forEach( diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index a8680c9a54be..c124aa90b963 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -32,6 +32,14 @@ export interface VcsDiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Limits the diff to these exact paths. An empty list yields an empty diff. */ + readonly filePaths?: ReadonlyArray; +} + +export interface VcsListAuthoredPathsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; } export interface VcsDeleteCheckpointRefsInput { @@ -48,6 +56,9 @@ export interface VcsCheckpointOps { input: VcsRestoreCheckpointInput, ) => Effect.Effect; readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect; + readonly listAuthoredPaths: ( + input: VcsListAuthoredPathsInput, + ) => Effect.Effect | null, VcsError>; readonly deleteCheckpointRefs: ( input: VcsDeleteCheckpointRefsInput, ) => Effect.Effect; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2e6785db07b9..3730eb6a18d4 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -3,6 +3,13 @@ T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish repositories, create pull requests, and review changes. +## Review turn changes + +A turn's changed files and diff show only the turn's own work. When a turn pulls, merges, or +rebases, the files Git brought in are left out. A file stays in the list when the turn edited it, +committed it, or fixed a conflict in it. Use the branch comparison to review everything that changed +against your base branch. Restore still returns the complete saved workspace. + ## Connect an account Install Git and configure authentication on the machine running your T3 Code server. For a remote From 07b80800124982bce94879a97767a5365987e8f1 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 10 Oct 2026 07:45:23 +0530 Subject: [PATCH 180/273] fix(web): place notification icons after titles (#12209) --- apps/web/src/components/ui/toast.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 27ff2a2733a9..9448bfff99ea 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -340,6 +340,7 @@ function ToastBodyContent({ )} >
+ {leadingIcon ? (
) : null} -
Date: Fri, 9 Oct 2026 22:17:46 -0400 Subject: [PATCH 181/273] fix(web): attachments on an open question are visible again (#15537) --- apps/web/src/components/chat/ChatComposer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9ef3b1d81cf4..0d55c1c83e06 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -7094,7 +7094,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {!isComposerCollapsedMobile && !isComposerApprovalState && - pendingUserInputs.length === 0 && (uncommittedSnapShotIds.length > 0 || composerVideos.length > 0 || expandedComposerImages.length > 0) && ( From f34adad34f21510e867b47fe018c7975779a0ffc Mon Sep 17 00:00:00 2001 From: "M.Umais Adeed" <165276382+Umais-Adeed@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:18:58 +0500 Subject: [PATCH 182/273] fix(web): scale Files tree with interface font size (#8011) --- apps/web/src/pierre-tree-theme.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pierre-tree-theme.ts b/apps/web/src/pierre-tree-theme.ts index 906b34ba1c51..3f191d255789 100644 --- a/apps/web/src/pierre-tree-theme.ts +++ b/apps/web/src/pierre-tree-theme.ts @@ -8,7 +8,7 @@ export const PIERRE_TREE_UNSAFE_CSS = ` --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); --trees-font-family-override: var(--font-sans); - --trees-font-size-override: 12px; + --trees-font-size-override: 0.75rem; } button[data-type='item'] { border-radius: 5px; } svg[data-icon-name='t3-tree-icon-loading'] { opacity: 0.6; } From 318201caa520525517ef2bee8f4bbeedb2fec1d7 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:31:29 -0300 Subject: [PATCH 183/273] fix(server): probe only owned preview listeners (#16687) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/preview/PortScanner.test.ts | 163 +++++++++----------- apps/server/src/preview/PortScanner.ts | 79 ++++------ 2 files changed, 110 insertions(+), 132 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 8a6bf87d4699..8aae21d8cf71 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -7,7 +7,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; @@ -44,17 +43,7 @@ const layerTestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: processProbeFailure, }); -let integrationListeningPort: number | null = null; - -const layerTestIntegrationNet = Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), - hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), -}); - -/** A host without `/proc`, so a missing `lsof` falls back to common ports. */ +/** A host without `/proc`, so a missing `lsof` leaves only configured URLs. */ const layerNoProc = FileSystem.layerNoop({}); const layerProbeFailure = ( @@ -67,13 +56,6 @@ const layerProbeFailure = ( Layer.mergeAll( fileSystem, Layer.succeed(ProcessRunner.ProcessRunner, { run }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))), ), @@ -85,7 +67,6 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( Layer.mergeAll( layerNoProc, layerTestProcessRunner, - layerTestIntegrationNet, Layer.succeed(HostProcess.Platform, "win32"), FetchHttpClient.layer, ), @@ -94,9 +75,20 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( const LSOF_TEST_PORT = 43_123; +/** The scanner with `processIds` registered to a T3 terminal, so their listeners get probes. */ +const ownedScanner = (processIds: ReadonlyArray = [1234]) => + Effect.tap(PortScanner.PortDiscovery, (scanner) => + scanner.registerTerminalProcesses({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + processIds, + }), + ); + const layerLsofScanner = (input: { readonly pid: () => number; readonly fetch: typeof globalThis.fetch; + readonly stdout?: () => string; }) => PortScanner.layer.pipe( Layer.provide( @@ -105,7 +97,7 @@ const layerLsofScanner = (input: { Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ - stdout: `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, + stdout: input.stdout?.() ?? `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, stderr: "", code: null, timedOut: false, @@ -115,13 +107,6 @@ const layerLsofScanner = (input: { stderrInvalidUtf8: false, }), }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)), @@ -153,73 +138,45 @@ const closeServer = (server: NodeNet.Server): Effect.Effect => }); const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( - ports: ReadonlyArray, onConnection: (socket: NodeNet.Socket) => void, ) { - for (const port of ports) { - const server = yield* openServer(port, onConnection); - if (server !== null) return { port, server }; + const server = yield* openServer(0, onConnection); + const address = server?.address(); + if (!server || !address || typeof address === "string") { + return yield* Effect.die(new Error("Could not open the preview scanner test listener")); } - return yield* Effect.die( - new Error("No common development port was available for the preview scanner test"), - ); + return { port: address.port, server }; }); const commonDevServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => { + openCommonDevServer((socket) => { socket.once("data", () => { socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello"); }); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); const commonNonHttpServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS.toReversed(), (socket) => { + openCommonDevServer((socket) => { socket.on("error", () => undefined); socket.once("data", () => socket.end("MYSQL\r\n\r\n")); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); /** * Integration tests against a real TCP listener. We provide the Windows host - * platform so the tests exercise the TCP-probe fallback without depending on - * `lsof` being installed. + * platform with a failing listener probe so the tests exercise configured URLs + * without depending on `lsof` being installed. */ -effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fallback)", (it) => { +effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (configured URLs)", (it) => { it.effect( - "scan() returns an HTTP server we just opened on a curated dev port", + "scan() returns a configured HTTP server we just opened", Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); expect(found?.host).toBe("localhost"); @@ -231,7 +188,7 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () { const { port } = yield* commonNonHttpServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); expect(result.some((server) => server.port === port)).toBe(false); }), ); @@ -242,10 +199,12 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal const { port } = yield* commonDevServer; const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; - yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) => - Effect.sync(() => { - for (const server of servers) received.push(server.port); - }), + yield* scanner.subscribe( + { configuredUrls: [`http://localhost:${port}`], initialSnapshot: [] }, + (servers) => + Effect.sync(() => { + for (const server of servers) received.push(server.port); + }), ); yield* scanner.retain; expect(received).toContain(port); @@ -265,7 +224,7 @@ effectIt.effect("revalidates a successful HTML probe after its cache entry expir const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); @@ -499,7 +458,7 @@ effectIt.effect("shares a configured root probe with discovered-root classificat const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan([rootUrl])).toHaveLength(1); expect(requests).toEqual([rootUrl]); @@ -528,7 +487,7 @@ effectIt.effect("starts fresh cache entries after the probing batch completes", const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); yield* Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toHaveLength(1); @@ -548,7 +507,7 @@ effectIt.effect("caches a failed web probe until its bounded cache entry expires const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(0); expect(yield* scanner.scan()).toHaveLength(0); expect(requests).toHaveLength(2); @@ -573,7 +532,7 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const servers = yield* scanner.scan(); expect(servers).toHaveLength(1); expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); @@ -581,6 +540,37 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing }).pipe(Effect.provide(layer)); }); +effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { + const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; + const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; + let stdout = unowned; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn, stdout: () => stdout }); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan()).toHaveLength(0); + stdout = `${owned}${unowned}`; + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toEqual([]); + + stdout = owned; + expect(yield* scanner.scan()).toHaveLength(1); + expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + + yield* scanner.unregisterTerminal({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + }); + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); +}); + effectIt.effect( "excludes HTTP errors, non-navigation responses, and successful non-documents", () => { @@ -592,7 +582,7 @@ effectIt.effect( const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); expect(yield* scanner.scan()).toHaveLength(0); pid += 1; @@ -650,7 +640,7 @@ effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => { const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const scanFiber = yield* Effect.forkChild(scanner.scan()); yield* TestClock.adjust(Duration.seconds(2)); expect(yield* Fiber.join(scanFiber)).toHaveLength(0); @@ -739,11 +729,10 @@ effectIt.effect( ); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); + // Ports 22 and 3001 have no known owner, so they never receive probes. expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 22, pid: null, processName: null }, - { port: 3001, pid: null, processName: null }, { port: 8765, pid: 4242, processName: "python3" }, ]); yield* scanner.scan(); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 9b1d9e966333..7a59079ea9f4 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -8,8 +8,8 @@ * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and * finds their processes through `/proc//fd`. * - * Windows listener failures, or neither source: checks a curated list of - * common dev ports through the shared Net service. + * Only listeners owned by T3 terminal processes and explicitly configured + * URLs receive probes. Without listener ownership, only configured URLs do. * * Listening ports are published only after a bounded HTTP(S) probe finds a * successful HTML document or a redirect to one. @@ -26,7 +26,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -71,10 +70,6 @@ export class PortDiscovery extends Context.Service< } >()("t3/preview/PortScanner/PortDiscovery") {} -export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ - 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, -]); - const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; /** File descriptors one `/proc` walk may read before it stops looking for socket owners. */ @@ -218,14 +213,20 @@ const parseLsofOutput = ( if (portMatch == null) continue; const url = `http://localhost:${portMatch}`; const key = `localhost:${portMatch}`; - if (seen.has(key)) continue; + const terminal = pid === null ? null : (terminalByProcessId.get(pid) ?? null); + const existing = seen.get(key); + // A port receives probes only when T3 terminals own all of its listeners. + if (existing) { + if (terminal === null) seen.set(key, { ...existing, terminal: null }); + continue; + } seen.set(key, { host: "localhost", port: portMatch, url, processName, pid, - terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), + terminal, }); } } @@ -261,14 +262,20 @@ const parseWindowsListenerOutput = ( const pid = Number(pidRaw); if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; - if (seen.has(port)) continue; + const terminal = + normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null); + const existing = seen.get(port); + if (existing) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: processNameRaw?.trim() || null, pid: normalizedPid, - terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), + terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); @@ -339,7 +346,6 @@ const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { - const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; @@ -398,7 +404,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { /** * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null - * when neither file is readable, so discovery falls back to common ports. + * when neither file is readable, so only configured URLs are probed. */ const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( terminalByProcessId: ReadonlyMap, @@ -433,44 +439,25 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const seen = new Map(); for (const { port, inode } of listeners) { const owner = owners.get(inode) ?? null; + const terminal = owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null); const existing = seen.get(port); - if (existing && (existing.pid !== null || owner === null)) continue; + if (existing && (existing.pid !== null || owner === null)) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: owner?.processName ?? null, pid: owner?.pid ?? null, - terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null), + // A replaced listener had no known owner, so the port stays unowned. + terminal: existing ? null : terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); }); - const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { - const results = yield* Effect.forEach( - COMMON_DEV_PORTS, - (port) => - net.isPortAvailableOnLoopback(port).pipe( - Effect.map((available) => ({ - port, - listening: !available, - })), - ), - { concurrency: "unbounded" }, - ); - return results - .filter((result) => result.listening) - .map((result) => ({ - host: "localhost", - port: result.port, - url: `http://localhost:${result.port}`, - processName: null, - pid: null, - terminal: null, - })); - }); - const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) => httpClient.get(url).pipe( Effect.map((response) => { @@ -524,6 +511,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } for (const server of servers) { + // A listener alone does not identify its protocol. Sending HTTP or TLS to + // another app's binary RPC listener can crash it before classification. + if (server.terminal === null) continue; groups.push({ server, urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], @@ -616,7 +606,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => - Effect.logDebug("preview port process probe failed; falling back to common-port probes", { + Effect.logDebug("preview port process probe failed; probing configured URLs only", { cause: error, probe, platform: hostPlatform, @@ -642,7 +632,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), @@ -655,20 +644,20 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }), ); if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + return yield* probeWebServers([], configuredUrls); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); if (yield* Ref.get(lsofMissingRef)) { const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); } const lsofResult = yield* processRunner .run({ command: "lsof", args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], timeout: Duration.millis(LSOF_TIMEOUT_MS), + // Partial output could hide another app's listener on an owned port. maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), @@ -686,7 +675,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( From ed4ea1083d477b67304f50d5691ba5fc2b9496cd Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:31:57 -0300 Subject: [PATCH 184/273] fix(chat): surface pending subagent questions on parents (#16634) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../features/threads/ThreadDetailScreen.tsx | 24 +++++++++- .../threads/floating-working-control.tsx | 24 ++++++++-- .../threads/floating-working-status.ts | 6 +++ apps/mobile/src/state/entities.ts | 4 ++ apps/web/src/components/ChatView.tsx | 47 +++++++++++++++++-- apps/web/src/state/entities.ts | 6 +++ .../client-runtime/src/state/threadShell.ts | 46 ++++++++++++++++++ 7 files changed, 150 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9229c117d8da..a3d7a6f4e0c6 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useThreadReportedModelSelection } from "../../state/entities"; +import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { useNavigation } from "@react-navigation/native"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; @@ -400,6 +400,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const controlInsets = deriveBottomControlInsets(nativeMetrics); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const childThreadInputs = useChildThreadInputs({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + }); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; const queuedCount = useThreadQueuedCount({ environmentId: props.environmentId, @@ -507,6 +511,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } + const childInput = childThreadInputs[0]; + if (childInput && contentPresentationKind === "ready") { + return { + kind: "child-input", + label: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + accessibilityLabel: `Open question from ${childInput.title}`, + onPress: () => { + Keyboard.dismiss(); + navigation.navigate("Thread", { + environmentId: props.environmentId, + threadId: childInput.id, + }); + }, + }; + } if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index f21a83f09a17..1fb87ce1c725 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -160,15 +160,16 @@ export function FloatingWorkingControl(props: { hasAgents || hasQueue || (props.devicePreview !== null && props.browserPreview !== null); - // The queue, agents, and reconnect labels have separate tap targets. - const statusInteractive = props.status?.kind === "connection"; + // The queue, agents, questions, and reconnect labels have separate tap targets. + const statusInteractive = + props.status?.kind === "connection" || props.status?.kind === "child-input"; const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; // The host stays centered on the capsule, but its measurement constraint // comes from the overlay, independent of the capsule's current width. const statusContent = props.status !== null ? ( @@ -372,6 +373,23 @@ function FloatingStatusLabel(props: { if (props.status.kind === "compacting") { return ; } + if (props.status.kind === "child-input") { + return ( + + + + {props.status.label} + + + ); + } if (props.status.kind === "connection") { return ( void; + } // The turn settled while background work it started still runs. `waiting` // is false when only commands remain, such as a dev server: the agent is done. | { diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 0d3b33d582d5..b8e42b0cb99e 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -98,6 +98,10 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref)); +} + /** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { const titles = useAtomValue( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a80fc56c57e6..06a6bacaf48c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -300,6 +300,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, + MessageCircleIcon, PaperclipIcon, ChevronDownIcon, DownloadIcon, @@ -427,6 +428,7 @@ import { useThreadStatus, useThreadHistory, useThreadShell, + useChildThreadInputs, useThreadRefs, useThreadVisibleTurnItems, waitForThreadShell, @@ -7414,6 +7416,42 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, navigate], ); + const childThreadInputs = useChildThreadInputs(activeThreadRef); + const childInputBannerItem = useMemo(() => { + const first = childThreadInputs[0]; + const parentAwaitingUser = + activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; + if (!first || parentAwaitingUser) return null; + return { + id: `child-input:${first.id}`, + variant: "info", + priority: "activity", + icon: , + title: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + description: childThreadInputs.map((child, index) => ( + + {index > 0 ? ", " : null} + onOpenRelatedThread(child.id)}> + {child.title} + + + )), + actions: ( + + ), + }; + }, [ + childThreadInputs, + activePendingApproval, + activePendingUserInput, + activeThreadShell?.hasPendingUserInput, + onOpenRelatedThread, + ]); // Commands such as /compact and /goal clear run as their own turn. The draft // and its attachments stay local. @@ -7774,9 +7812,11 @@ export default function ChatView(props: ChatViewProps) { : null; const composerBannerItems = useMemo(() => { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; - const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter( - (item) => item !== null, - ); + const backgroundWorkItems = [ + childInputBannerItem, + goalBannerItem, + backgroundWorkBannerItem, + ].filter((item) => item !== null); // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; @@ -7847,6 +7887,7 @@ export default function ChatView(props: ChatViewProps) { handleRestoreThreadBranch, isRestoringThreadBranch, backgroundWorkBannerItem, + childInputBannerItem, goalBannerItem, localCheckoutBranchMismatch, projectCloneBannerItem, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index aa9b650fb5b5..ebf62f01be6f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -117,6 +117,12 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef | null) { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELLS_ATOM : environmentThreadShells.childThreadInputsAtom(ref), + ); +} + export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null { return useAtomValue( ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.threadAtom(ref), diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index bc6d444f471a..3cb54087ac14 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -7,6 +7,7 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; +import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell } from "./models.ts"; @@ -138,6 +139,50 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); }); + // Child questions are hidden with their sidebar rows. Read their shell summaries, + // without subscribing to every child transcript. + const childThreadInputsAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const children = new Map(); + for (const thread of get(environmentThreadsAtom(ref.environmentId))) { + const parent = thread.lineage.parentThreadId; + if (parent === null || thread.lineage.relationshipToParent !== "subagent") continue; + const siblings = children.get(parent); + if (siblings) siblings.push(thread); + else children.set(parent, [thread]); + } + const seen = new Set([ref.threadId]); + const pending = [ref.threadId]; + const next: EnvironmentThreadShell[] = []; + for (const parent of pending) { + for (const child of children.get(parent) ?? []) { + if (seen.has(child.id)) continue; + seen.add(child.id); + pending.push(child.id); + if ( + child.pendingRuntimeRequest?.kind === "user_input" && + !isProviderNativeSubagentThread(child) + ) { + next.push(scopedThread(ref.environmentId, child)); + } + } + } + // Indicators read only ids and titles, so other shell updates keep the old array. + if ( + previous.length === next.length && + previous.every( + (thread, index) => thread.id === next[index]?.id && thread.title === next[index]?.title, + ) + ) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-child-thread-inputs:${key}`)); + }); + const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { const projectRefs = parseProjectRefCollectionKey(key); let previous: ReadonlyArray = []; @@ -225,5 +270,6 @@ export function createEnvironmentThreadShellAtoms(input: { threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + childThreadInputsAtom: (ref: ScopedThreadRef) => childThreadInputsAtomFamily(threadKey(ref)), }; } From c1f21e53b3b138cc591f9df90bd5c8aef6a00ce4 Mon Sep 17 00:00:00 2001 From: Bishal Banerjee <56464152+ZenderGoD@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:09:30 +0530 Subject: [PATCH 185/273] fix(web): section header chevrons point up when collapsed (#14273) Co-authored-by: zendergod Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ui/collapsible-section-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/collapsible-section-header.tsx b/apps/web/src/components/ui/collapsible-section-header.tsx index 5cc52ffe2950..6a685fe03acd 100644 --- a/apps/web/src/components/ui/collapsible-section-header.tsx +++ b/apps/web/src/components/ui/collapsible-section-header.tsx @@ -36,7 +36,7 @@ export function CollapsibleSectionHeader({ {accessory} ); From e08c31e1a1fd5e89e2d4130b9ad18b44d15f92cd Mon Sep 17 00:00:00 2001 From: jonesfionn101-dotcom Date: Sat, 10 Oct 2026 03:39:40 +0100 Subject: [PATCH 186/273] fix(web): timeline divider pill shows a pointer, visible hover and focus ring (#15188) Co-authored-by: Fionn Jones <264670603+jonesfionn101-dotcom@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/chat/TimelineSystemDivider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/TimelineSystemDivider.tsx b/apps/web/src/components/chat/TimelineSystemDivider.tsx index 1c4be795007c..e680cab50c4c 100644 --- a/apps/web/src/components/chat/TimelineSystemDivider.tsx +++ b/apps/web/src/components/chat/TimelineSystemDivider.tsx @@ -48,7 +48,7 @@ export function TimelineSystemDivider(props: { type="button" aria-label={props.actionLabel} onClick={props.onAction} - className="flex min-w-0 items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 transition-colors hover:bg-muted" + className="flex min-w-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70" /> } > From 6e4ca6ce9d04009af290412b77a0acb9be9f8a18 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:48:05 -0300 Subject: [PATCH 187/273] fix(git): allow creating prs from dirty worktrees (#15625) --- apps/server/src/git/GitManager.test.ts | 32 +++++++++++++++++-- apps/server/src/git/GitManager.ts | 8 ----- .../GitActionsControl.logic.test.ts | 12 +++++-- .../src/components/GitActionsControl.logic.ts | 1 - apps/web/src/components/GitActionsControl.tsx | 5 +-- .../client-runtime/src/state/gitActions.ts | 8 ++--- 6 files changed, 42 insertions(+), 24 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 45f4ae96a133..c9a08607bc08 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3743,7 +3743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr pushes a clean branch before creating the PR when needed", () => + it.effect("create_pr pushes committed changes while preserving a dirty worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -3753,6 +3753,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); yield* runGit(repoDir, ["add", "create-pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "uncommitted readme\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); + const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; const { manager, ghCalls } = yield* makeManager({ ghScenario: { @@ -3781,6 +3785,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.push.setUpstream).toBe(true); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(303); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); + expect( + (yield* runGit(remoteDir, ["rev-parse", "feature/create-pr-only"])).stdout.trim(), + ).toBe(headBefore); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature/create-pr-only"), @@ -6199,7 +6207,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr emits only the PR phase when the branch is already pushed", () => + it.effect("create_pr preserves dirty work on an already pushed branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -6210,8 +6218,22 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["add", "pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout; + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "staged readme\n"); + yield* runGit(repoDir, ["add", "README.md"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "unstaged feature work\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); + const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; + const stagedBefore = (yield* runGit(repoDir, ["diff", "--cached"])).stdout; + let generatedContent: TextGeneration.PrContentGenerationInput | undefined; const { manager } = yield* makeManager({ + textGeneration: { + generatePrContent: (input) => { + generatedContent = input; + return Effect.succeed({ title: "PR only branch", body: "Committed feature work" }); + }, + }, ghScenario: { prListSequence: [ JSON.stringify([]), @@ -6251,6 +6273,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.commit.status).toBe("skipped_not_requested"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); + expect(generatedContent?.diffPatch).toContain("+pr only"); + expect(generatedContent?.diffPatch).not.toContain("staged readme"); + expect(generatedContent?.diffPatch).not.toContain("unstaged feature work"); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); + expect((yield* runGit(repoDir, ["diff", "--cached"])).stdout).toBe(stagedBefore); expect( events.filter( (event): event is Extract => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8ca0b6b976a9..bd4ee0da7e9d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2730,14 +2730,6 @@ export const make = Effect.gen(function* () { detail: "Feature-branch checkout is only supported for commit actions.", }); } - if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* new GitManagerError({ - operation: "runStackedAction", - cwd: input.cwd, - detail: "Commit local changes before creating a PR.", - }); - } - const phases: GitActionProgressPhase[] = [ ...(input.featureBranch ? (["branch"] as const) : []), ...(wantsCommit ? (["commit"] as const) : []), diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 6d33d412ec8c..b29a44be3702 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -365,7 +365,7 @@ describe("when: source control provider uses merge requests", () => { }); describe("when: ref is clean, up to date, and has no open PR", () => { - it("enables create PR when synced with upstream but ahead of default", () => { + it("enables create PR when synced with upstream but ahead of default, even with local changes", () => { const syncedFeature = status({ aheadCount: 0, behindCount: 0, @@ -383,6 +383,12 @@ describe("when: ref is clean, up to date, and has no open PR", () => { const items = buildMenuItems(syncedFeature, false); assert.equal(items.find((item) => item.id === "pr")?.disabled, false); + + const dirtyFeature = { ...syncedFeature, hasWorkingTreeChanges: true }; + assert.equal( + buildMenuItems(dirtyFeature, false).find((item) => item.id === "pr")?.disabled, + false, + ); }); it("resolveQuickAction returns disabled no-action state", () => { @@ -550,7 +556,7 @@ describe("when: working tree has local changes", () => { ]); }); - it("buildMenuItems enables push for ahead commits while local changes remain uncommitted", () => { + it("buildMenuItems enables push and create PR for ahead commits while local changes remain uncommitted", () => { const items = buildMenuItems( status({ refName: "feature/test", @@ -584,7 +590,7 @@ describe("when: working tree has local changes", () => { { id: "pr", label: "Create PR", - disabled: true, + disabled: false, icon: "pr", kind: "open_dialog", dialogAction: "create_pr", diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 8d3b7bc30578..5422628825a6 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -184,7 +184,6 @@ export function buildMenuItems( const canCreatePr = !isBusy && hasBranch && - !hasChanges && !hasOpenPr && hasDefaultBranchDelta && !isBehind && diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 5615c64ee450..cfe45ff24cc4 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -338,13 +338,10 @@ function getMenuActionDisabledReason({ if (!hasBranch) { return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`; } - if (hasChanges) { - return `Commit local changes before creating a ${terminology.singular}.`; - } if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return `Add an "origin" remote before creating a ${terminology.singular}.`; } - if (!isAhead) { + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { return `No local commits to include in a ${terminology.singular}.`; } if (isBehind) { diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index d3571e65d2e6..f708e9ca9b98 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -65,9 +65,8 @@ export function buildMenuItems( const canCreatePr = !isBusy && hasBranch && - !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0 && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; @@ -292,13 +291,10 @@ export function getGitActionDisabledReason(input: { if (!hasBranch) { return "Detached HEAD: checkout a branch before creating a PR."; } - if (hasChanges) { - return "Commit local changes before creating a PR."; - } if (!gitStatus.hasUpstream && !hasOriginRemote) { return 'Add an "origin" remote before creating a PR.'; } - if (!isAhead) { + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { return "No local commits to include in a PR."; } if (isBehind) { From b822420e5074d750e7258ea48568666720cf0415 Mon Sep 17 00:00:00 2001 From: Ege <68082325+ege-arhan@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:57:13 +0300 Subject: [PATCH 188/273] docs(install): polish binary install destination phrasing (#15732) --- docs/user/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/install.md b/docs/user/install.md index 948e5d367607..67df94217ac3 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -20,7 +20,7 @@ On Windows, in PowerShell: irm https://t3.codes/install.ps1 | iex ``` -This puts `t3` in `~/.local/bin`. If your shell reports `command not found` +This installs the `t3` binary to `~/.local/bin`. If your shell reports `command not found` afterwards, that directory is not on your `PATH` yet; the installer prints the line to add. Set `T3CODE_CHANNEL=nightly` to install the nightly train, or `T3CODE_VERSION` to pin an exact version. From 0981e6b9d421ec46010d9b9edebd0beb241ef472 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:57:34 +0200 Subject: [PATCH 189/273] perf(server): keep passive terminal output flowing (#17178) --- apps/server/src/terminal/OutputProtocol.test.ts | 2 ++ apps/server/src/terminal/OutputProtocol.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts index 548a09c08686..a12f9d6ecbf8 100644 --- a/apps/server/src/terminal/OutputProtocol.test.ts +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -13,8 +13,10 @@ import { withTerminalOutputWindow } from "./OutputProtocol.ts"; describe("terminal output window", () => { it.effect.each([ { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 }, + { tag: WS_METHODS.terminalObserve, size: 1, limit: 8 }, { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 }, { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 }, + { tag: WS_METHODS.terminalObserve, size: 64 * 1024, limit: 1 }, { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 }, ])("limits $tag with $size-byte values to $limit pending chunks", ({ tag, size, limit }) => Effect.gen(function* () { diff --git a/apps/server/src/terminal/OutputProtocol.ts b/apps/server/src/terminal/OutputProtocol.ts index 441961a7952f..402ed32c022a 100644 --- a/apps/server/src/terminal/OutputProtocol.ts +++ b/apps/server/src/terminal/OutputProtocol.ts @@ -22,6 +22,7 @@ export function withTerminalOutputWindow( if ( message._tag === "Request" && (message.tag === WS_METHODS.terminalAttach || + message.tag === WS_METHODS.terminalObserve || message.tag === WS_METHODS.subscribeTerminalEvents) ) { const key = `${clientId}:${message.id}`; From b6aa268b12e8eb6d5aab52e5e09c12e255fca905 Mon Sep 17 00:00:00 2001 From: Anneshu Nag <132702983+NK-Works@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:28:01 +0530 Subject: [PATCH 190/273] fix(web): stop button icon no longer shifts on hover (#16012) --- apps/web/src/components/chat/ComposerPrimaryActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index ac8a70e66553..85d8504ce256 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -138,7 +138,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ } /> - {pendingAction === "enable-auto-merge" ? "Enabling..." : pendingAutoMergeLabel} + {pendingAction === "enable-auto-merge" + ? "Enabling..." + : inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : pendingAutoMergeLabel} ) : primaryAction === "auto-merge-armed" ? ( @@ -1970,21 +1962,44 @@ export function PullRequestDetailPanel({ size="xs" variant="default" disabled={actionPending} - onClick={() => setConfirmation({ open: true, action: "merge" })} + {...inlineConfirm.props( + "merge", + () => void perform("merge", selectedMergeMethod), + )} aria-label={ - pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel + pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel } > - - {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} + + + {selectedMergeMethodLabel} + + + Confirm merge? + + + {pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel} + } /> - {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} + {pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel} ) : (primaryAction === "merged" || primaryAction === "closed") && @@ -2101,11 +2116,18 @@ export function PullRequestDetailPanel({ ) : null} {showsMergeNow ? ( setConfirmation({ open: true, action: "merge" })} + {...inlineConfirm.props( + "merge", + () => void perform("merge", selectedMergeMethod), + )} > - Merge now + {inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"} ) : null} {/* The same merge, left with the host to carry out once its requirements @@ -2121,13 +2143,22 @@ export function PullRequestDetailPanel({ ) : showsAutoMerge ? ( - setConfirmation({ open: true, action: "enable-auto-merge" }) + closeOnClick={inlineConfirm.isArmed("enable-auto-merge")} + aria-label={ + inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : "Enable auto-merge" } + disabled={actionPending} + {...inlineConfirm.props( + "enable-auto-merge", + () => void perform("enable-auto-merge", selectedMergeMethod), + )} > - Enable auto-merge + {inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : "Enable auto-merge"} ) : null} {/* A preference for the merge action rather than a second action, so it @@ -2200,12 +2231,16 @@ export function PullRequestDetailPanel({ <> setConfirmation({ open: true, action: "close" })} + {...inlineConfirm.props("close", () => void perform("close"))} > - Close pull request + {inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"} ) : detail.state === "closed" && can("reopen") ? ( @@ -2220,11 +2255,16 @@ export function PullRequestDetailPanel({ <> setConfirmation({ open: true, action: "revert" })} + {...inlineConfirm.props("revert", () => void perform("revert"))} > - Revert changes + {inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"} ) : null} @@ -2553,20 +2593,33 @@ export function PullRequestDetailPanel({ size="xs" variant="warning-outline" disabled={actionPending} - onClick={() => - setConfirmation({ open: true, action: "approve-workflows" }) - } + {...inlineConfirm.props( + "approve-workflows", + () => void perform("approve-workflows"), + )} aria-label={ pendingAction === "approve-workflows" ? "Approving..." - : "Approve workflows to run" + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run" } > - - {pendingAction === "approve-workflows" - ? "Approving..." - : "Approve workflows to run"} + + + Approve workflows to run + + + Confirm approve? + + + {pendingAction === "approve-workflows" + ? "Approving..." + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run"} + @@ -2575,7 +2628,9 @@ export function PullRequestDetailPanel({ {pendingAction === "approve-workflows" ? "Approving..." - : "Approve workflows to run"} + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run"} ) : ( @@ -2802,74 +2857,6 @@ export function PullRequestDetailPanel({ />
) : null} - - setConfirmation((current) => ({ ...current, open }))} - onOpenChangeComplete={(open) => { - if (!open) setConfirmation({ open: false, action: "merge" }); - }} - > - - - - {confirmAction === "merge" - ? "Merge pull request?" - : confirmAction === "enable-auto-merge" - ? "Enable auto-merge?" - : confirmAction === "revert" - ? "Revert these changes?" - : confirmAction === "approve-workflows" - ? "Approve workflows to run?" - : "Close pull request?"} - - - {confirmAction === "merge" - ? `This merges #${reference.number} using ${selectedMergeMethod}.` - : confirmAction === "enable-auto-merge" - ? // The host merges this as soon as it considers the pull request ready, which - // may be immediately — there is no telling from here whether anything is - // still outstanding. - `This merges #${reference.number} using ${selectedMergeMethod} as soon as the host considers it ready, which may be immediately.` - : confirmAction === "revert" - ? `This opens a new pull request that reverses the changes merged by #${reference.number}.` - : confirmAction === "approve-workflows" - ? `This allows ${workflowApprovalsRequired} ${workflowApprovalsRequired === 1 ? "workflow" : "workflows"} from #${reference.number} to run. Review the code and workflow changes first.` - : `This closes #${reference.number} without merging it.`} - - - - }> - Cancel - - - - -

); } diff --git a/apps/web/src/components/pullRequest/useInlineActionConfirm.ts b/apps/web/src/components/pullRequest/useInlineActionConfirm.ts new file mode 100644 index 000000000000..5b4f7fc57212 --- /dev/null +++ b/apps/web/src/components/pullRequest/useInlineActionConfirm.ts @@ -0,0 +1,55 @@ +import { useEffect, useState, type MouseEvent } from "react"; + +/** Arms one control at a time, including controls rendered in a menu portal. */ +export function useInlineActionConfirm(scopeKey: string) { + const [armed, setArmed] = useState<{ + scopeKey: string; + action: string; + element: HTMLElement; + } | null>(null); + const active = armed?.scopeKey === scopeKey ? armed : null; + + useEffect(() => { + if (!armed) return; + const disarm = () => setArmed(null); + if (armed.scopeKey !== scopeKey) { + disarm(); + return; + } + const onPointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && !armed.element.contains(event.target)) disarm(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + disarm(); + } + }; + const timeout = window.setTimeout(disarm, 3000); + document.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("blur", disarm); + return () => { + window.clearTimeout(timeout); + document.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("blur", disarm); + }; + }, [armed, scopeKey]); + + return { + isArmed: (action: string) => active?.action === action, + props: (action: string, onConfirm: () => void) => ({ + onClick: (event: MouseEvent) => { + if (active?.action === action && active.element === event.currentTarget) { + setArmed(null); + onConfirm(); + } else { + setArmed({ scopeKey, action, element: event.currentTarget }); + } + }, + onBlur: () => setArmed(null), + }), + }; +} From 4107930420cffe14776ad80783f7060236b5868b Mon Sep 17 00:00:00 2001 From: eimexdev <130890337+eimexdev@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:38:39 -0700 Subject: [PATCH 221/273] feat(web): reorder right panel tabs by dragging (#17730) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 9 + apps/web/src/components/RightPanelTabs.tsx | 330 ++++++++++++-------- apps/web/src/rightPanelStore.test.ts | 23 ++ apps/web/src/rightPanelStore.ts | 28 +- apps/web/src/routes/_chat.pull-requests.tsx | 4 + 5 files changed, 260 insertions(+), 134 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 06a6bacaf48c..2aeca85e6aa2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6233,6 +6233,13 @@ export default function ChatView(props: ChatViewProps) { finishRightPanelSurfaceClose, rightPanelState.surfaces, ]); + const moveRightPanelSurface = useCallback( + (surfaceId: string, toIndex: number) => { + if (activeThreadRef) + useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); + }, + [activeThreadRef], + ); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -11991,6 +11998,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} @@ -12049,6 +12057,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 6c1206929b4c..5c8323a7ae34 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -14,6 +14,17 @@ import type { ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { + closestCenter, + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Smartphone, ChevronDown, @@ -27,6 +38,7 @@ import { } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { + type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -115,6 +127,8 @@ interface RightPanelTabsProps { onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; + /** Tabs are draggable only when the owner can persist the new order. */ + onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; /** @@ -199,6 +213,7 @@ type TabContextMenuAction = | "close-all"; const TAB_SCROLL_EDGE_TOLERANCE = 1; +const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; @@ -795,6 +810,61 @@ function PullRequestSurfaceIcon({ return ; } +/** + * Lets its tabs be dragged into a new order. Neighbours slide aside while dragging, + * and the order commits on drop. + */ +function SortableTabList(props: { + surfaces: readonly RightPanelSurface[]; + onMove: ((surfaceId: string, toIndex: number) => void) | undefined; + children: ReactNode; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const surfaceIds = useMemo(() => props.surfaces.map((surface) => surface.id), [props.surfaces]); + const { onMove, surfaces } = props; + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + const toIndex = surfaces.findIndex((surface) => surface.id === over.id); + if (toIndex >= 0) onMove?.(String(active.id), toIndex); + }, + [onMove, surfaces], + ); + return ( + + + {props.children} + + + ); +} + +function SortableTab({ + surfaceId, + disabled, + className, + ...props +}: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: surfaceId, + disabled, + }); + return ( +
+ ); +} + export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; @@ -1118,135 +1188,143 @@ export function RightPanelTabs(props: RightPanelTabsProps) { data-right-panel-tab-list >
- {props.surfaces.map((surface) => { - const active = surface.id === props.activeSurfaceId; - const pending = props.pendingSurfaceIds.has(surface.id); - const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); - const previewTabId = previewTabIdOf(surface, props.previewSessions); - // Desktop state is keyed by the session id, but desktop actions - // must be addressed with the runtime id. - const audio = tabAudioState( - previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, - ); - const audioRuntimeTabId = previewTabId - ? (props.previewRuntimeTabId?.(previewTabId) ?? null) - : null; - return ( -
handleTabAuxClick(event, surface)} - onContextMenu={(event) => void handleTabContextMenu(event, surface)} - className={cn( - "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", - ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - active - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", - )} - > - props.onCloseSurface(surface)} + + {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const pending = props.pendingSurfaceIds.has(surface.id); + const title = surfaceTitle( + surface, + props.previewSessions, + props.terminalLabelsById, + ); + const previewTabId = previewTabIdOf(surface, props.previewSessions); + // Desktop state is keyed by the session id, but desktop actions + // must be addressed with the runtime id. + const audio = tabAudioState( + previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, + ); + const audioRuntimeTabId = previewTabId + ? (props.previewRuntimeTabId?.(previewTabId) ?? null) + : null; + return ( + handleTabAuxClick(event, surface)} + onContextMenu={(event) => void handleTabContextMenu(event, surface)} + className={cn( + "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", + active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + )} > - - {pending ? ( - - ) : null} - - {audio === "none" || !audioRuntimeTabId ? null : ( - - { - // Sibling of the close button, inside a tab that - // activates on click: keep this to the toggle. - event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); - }} - > - - - } + props.onCloseSurface(surface)} + > + - {audio === "muted" ? "Unmute tab" : "Mute tab"} - - )} - {renamingDevice === surface.id ? ( - { - element?.focus(); - element?.select(); - }} - onBlur={(event) => { - props.onRenameDevice?.(surface.id, event.currentTarget.value); - setRenamingDevice(null); - }} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = title; - event.currentTarget.blur(); - } - }} - /> - ) : ( - - { - if (surface.kind === "device" && props.onRenameDevice) - setRenamingDevice(surface.id); - }} - className="cursor-pointer flex min-w-0 items-center" - onClick={() => props.onActivate(surface)} - > - {title} - - } + {pending ? ( + + ) : null} + + {audio === "none" || !audioRuntimeTabId ? null : ( + + { + // Sibling of the close button, inside a tab that + // activates on click: keep this to the toggle. + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + + + } + /> + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + )} + {renamingDevice === surface.id ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => { + props.onRenameDevice?.(surface.id, event.currentTarget.value); + setRenamingDevice(null); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} /> - - {surface.kind === "device" ? ( - - ) : ( - title - )} - - - )} -
- ); - })} + ) : ( + + { + if (surface.kind === "device" && props.onRenameDevice) + setRenamingDevice(surface.id); + }} + className="cursor-pointer flex min-w-0 items-center" + onClick={() => props.onActivate(surface)} + > + {title} + + } + /> + + {surface.kind === "device" ? ( + + ) : ( + title + )} + + + )} + + ); + })} + {props.open !== false ? (
{ ), ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]); }); + + it("moves a surface to a new index and keeps it there through browser reconciliation", () => { + const store = useRightPanelStore.getState(); + store.openTerminal(refA, "term-1"); + store.openBrowser(refA, "tab-a"); + store.open(refA, "diff"); + const revision = store.getUserActionRevision(refA); + const surfaceIds = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ); + + store.moveSurface(refA, "browser:tab-a", 0); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff"]); + store.moveSurface(refA, "browser:tab-a", 2); + expect(surfaceIds()).toEqual(["terminal:term-1", "diff", "browser:tab-a"]); + store.moveSurface(refA, "browser:tab-a", 0); + + store.reconcileBrowserSurfaces(refA, ["tab-a", "tab-b"]); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff", "browser:tab-b"]); + // Reordering is not a choice about what the panel shows. + expect(store.getUserActionRevision(refA)).toBe(revision); + }); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 812de8804046..8afdbedfcc87 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -176,6 +176,7 @@ interface RightPanelStoreState { closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; closeAllSurfaces: (ref: ScopedThreadRef) => void; + moveSurface: (ref: ScopedThreadRef, surfaceId: string, toIndex: number) => void; reconcileBrowserSurfaces: ( ref: ScopedThreadRef, tabIds: readonly string[], @@ -893,22 +894,33 @@ export const useRightPanelStore = create()( : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), ), + // Reordering is not a choice about what the panel shows, so it leaves the + // user-action revision alone and proactive opens still apply. + moveSurface: (ref, surfaceId, toIndex) => + set((state) => + updateThread(state, scopedThreadKey(ref), (current) => { + const fromIndex = current.surfaces.findIndex((surface) => surface.id === surfaceId); + if (fromIndex < 0 || fromIndex === toIndex) return current; + const surfaces = [...current.surfaces]; + surfaces.splice(toIndex, 0, ...surfaces.splice(fromIndex, 1)); + return { ...current, surfaces }; + }), + ), reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) => set((state) => automaticUpdate(state, scopedThreadKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); - const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); - const existingBrowser = current.surfaces.filter( - (surface): surface is Extract => - surface.kind === "preview" && - surface.id !== "browser:new" && - validIds.has(surface.id), + // Filtered in place so browser tabs keep the order the user dragged them into. + const kept = current.surfaces.filter( + (surface) => + surface.kind !== "preview" || + (surface.id !== "browser:new" && validIds.has(surface.id)), ); - const knownIds = new Set(existingBrowser.map((surface) => surface.id)); + const knownIds = new Set(kept.map((surface) => surface.id)); const added = tabIds .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId)) .map((tabId) => browserSurface(tabId)); - const surfaces = [...nonBrowser, ...existingBrowser, ...added]; + const surfaces = [...kept, ...added]; const activeStillExists = surfaces.some( (surface) => surface.id === current.activeSurfaceId, ); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index c6b0591ee257..02e0e9e26d46 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2241,6 +2241,10 @@ function PullRequestsRouteView() { if (surface.kind === "pull-request") closeSurfacesToRight(surface); }} onCloseAllSurfaces={closeAllSurfaces} + onMoveSurface={(surfaceId, toIndex) => { + if (rightPanelRef !== null) + useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); + }} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} onAddBrowserInProfile={() => undefined} From c899a0c06ab2b0ec8fe8d6a497bb345ac72a5e5e Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 02:39:17 -0300 Subject: [PATCH 222/273] fix(azure-devops): list pull requests with token sign-in and check out into worktrees (#17725) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/git/GitManager.test.ts | 100 ++++++++++++++++++ apps/server/src/git/GitManager.ts | 45 +++++++- .../src/server/AzureDevOpsCli.test.ts | 25 +++++ .../server/AzureDevOpsPullRequestCli.test.ts | 43 +++++++- .../src/server/AzureDevOpsPullRequestCli.ts | 37 ++++++- .../AzureDevOpsSourceControlProvider.test.ts | 1 + .../AzureDevOpsSourceControlProvider.ts | 3 +- .../src/server/azureDevOpsPullRequests.ts | 4 + 8 files changed, 248 insertions(+), 10 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index c9a08607bc08..79053fbb65eb 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4948,6 +4948,106 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("prepares a worktree from the head branch when the host has no pull ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-pull-ref"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "head.txt"), "head\n"); + yield* runGit(repoDir, ["add", "head.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Head without a pull ref"]); + yield* runGit(repoDir, ["push", "origin", "feature/no-pull-ref"]); + const headSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/no-pull-ref"]); + // The remote keeps its Azure spelling, which is what the pull request is compared with. + const remoteUrl = "https://dev.azure.com/org/project/_git/repo"; + yield* runGit(repoDir, ["remote", "set-url", "origin", remoteUrl]); + yield* runGit(repoDir, ["config", `url.${remoteDir}.insteadOf`, remoteUrl]); + + // A pull request of another repository in the organization is not this remote's branch. + const { manager: otherRepositoryManager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 79, + title: "Another repository's PR", + url: "https://dev.azure.com/org/project/_git/other/pullrequest/79", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "open", + isCrossRepository: false, + }, + }, + }); + yield* Effect.flip( + preparePullRequestThread(otherRepositoryManager, { + cwd: repoDir, + reference: "79", + mode: "worktree", + }), + ); + // Nor is the branch of a closed one, which may have moved past the head it closed with. + const { manager: mergedManager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 77, + title: "Merged PR", + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/77", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "merged", + isCrossRepository: false, + }, + }, + }); + yield* Effect.flip( + preparePullRequestThread(mergedManager, { + cwd: repoDir, + reference: "77", + mode: "worktree", + }), + ); + const localBranches = (yield* runGit(repoDir, ["branch", "--list"])).stdout; + expect(localBranches).not.toContain("feature/no-pull-ref"); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 78, + title: "Azure DevOps PR", + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/78", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "open", + isCrossRepository: false, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "78", + mode: "worktree", + }); + + expect(result.branch).toBe("feature/no-pull-ref"); + const worktreeHead = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "HEAD", + ])).stdout.trim(); + expect(worktreeHead).toBe(headSha); + const upstream = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(); + expect(upstream).toBe("origin/feature/no-pull-ref"); + }), + ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index bd4ee0da7e9d..c5d372f8fb2b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -54,6 +54,7 @@ import { sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { + canonicalRepositoryKey, getChangeRequestTerminologyForKind, isSshRemoteUrl, type ChangeRequestTerminology, @@ -958,11 +959,45 @@ export const make = Effect.gen(function* () { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { - yield* gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }); + yield* gitCore + .fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }) + .pipe( + // Azure DevOps, GitLab and Bitbucket publish no `refs/pull//head`. A head in the + // same repository is a branch on the primary remote, so it is fetched by name instead, + // but only when that remote is the pull request's own repository: Azure finds a pull + // request by number anywhere in the organization. Only while it is open, too: the + // branch of a closed one may have moved on past the head it was closed with. + Effect.catch((cause) => + pullRequest.isCrossRepository === true || pullRequest.state !== "open" + ? Effect.fail(cause) + : Effect.gen(function* () { + const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + const remoteUrl = yield* gitCore.readConfigValue( + cwd, + `remote.${remoteName}.url`, + ); + const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); + if ( + remoteUrl === null || + pullRequestKey === null || + canonicalRepositoryKey(pullRequestKey) !== + canonicalRepositoryKey(normalizeGitRemoteUrl(remoteUrl)) + ) { + return yield* cause; + } + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + }), + ), + ); return; } diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts index e4fca9c5ffae..0a6b4fd6fc57 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -87,6 +87,31 @@ describe("AzureDevOpsCli.layer", () => { cwd: "/repo", timeoutMs: 30_000, }); + assert.strictEqual(result.isCrossRepository, false); + }).pipe(Effect.provide(layer)), + ); + + it.effect("marks a pull request from a fork as cross-repository", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + pullRequestId: 43, + title: "From a fork", + sourceRefName: "refs/heads/main", + targetRefName: "refs/heads/main", + status: "active", + forkSource: { name: "refs/heads/main", repository: { name: "repo-fork" } }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ cwd: "/repo", reference: "43" }); + + assert.strictEqual(result.isCrossRepository, true); }).pipe(Effect.provide(layer)), ); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts index b3afaca03715..8ad71188e187 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts @@ -451,17 +451,54 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); - it.effect("fails when nobody is signed in", () => + it.effect("names a token sign-in by the viewer's own pull request", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const notLoggedIn = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 7, + cause: new Error("Please run 'az login' to setup account."), + }); + mockedExecute + .mockReturnValueOnce(Effect.fail(notLoggedIn)) + .mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify([{ ...pullRequestRow, createdBy: { uniqueName: "bilal@acme.dev" } }]), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(""))) + .mockReturnValueOnce(Effect.succeed(output("[]"))); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + assert.strictEqual(yield* cli.getViewer({ cwd: "/w" }), "bilal@acme.dev"); + expect(argsOfCall(1)).toEqual(expect.arrayContaining(["--creator", "me", "--top", "1"])); + // Someone who has opened nothing has no name to compare rows against. const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); - assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); }), ); + it.effect("fails when no sign-in can read pull requests", () => + Effect.gen(function* () { + const signedOut = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 7, + cause: new Error("signed out"), + }); + mockedExecute.mockReturnValue(Effect.fail(signedOut)); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); + + assert.strictEqual(error._tag, "AzureDevOpsCliAuthenticationError"); + }), + ); + it.effect("completes a pull request to merge it, squashing only when asked", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts index 70fe78b34bf9..b7d719725fe3 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -59,7 +59,7 @@ export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError error._tag !== "AzureDevOpsCliUnavailableError", + () => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + ...detectArgs, + "--creator", + "me", + "--status", + "all", + "--top", + "1", + ], + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodePullRequestListJson(result.stdout.trim() || "[]"); + const login = Result.isSuccess(decoded) + ? decoded.success.items[0]?.author?.login + : undefined; + return login === undefined + ? Effect.fail( + new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }), + ) + : Effect.succeed(login); + }), + ), + ), ), listPullRequests: (input) => diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts index 8e55d453b224..f0362851596d 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts @@ -23,6 +23,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" baseRefName: "main", headRefName: "feature/source-control", state: "closed", + isCrossRepository: false, closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), }), diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index dcb07e0632f3..f935fb808709 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -61,6 +61,7 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly isCrossRepository: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; @@ -77,7 +78,7 @@ function toChangeRequest(summary: { closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, - isCrossRepository: false, + isCrossRepository: summary.isCrossRepository, }; } diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts index 6179a61aa65d..7ffc398490cf 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts @@ -15,6 +15,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly isCrossRepository: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: Option.Option; @@ -39,6 +40,8 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ targetRefName: TrimmedNonEmptyString, status: Schema.String, isDraft: Schema.optional(Schema.Boolean), + /** The fork the head branch lives in. `az` prints `null` for a pull request from the same repo. */ + forkSource: Schema.optional(Schema.Unknown), creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), _links: Schema.optional( @@ -178,6 +181,7 @@ function normalizeAzureDevOpsPullRequestRecord( headRefName: normalizeRefName(raw.sourceRefName), state, ...(raw.isDraft === true ? { isDraft: true } : {}), + isCrossRepository: raw.forkSource != null, closedAt: state === "closed" ? terminalAt : null, mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( From 0be20c86c2962b7b581dfc79df9ab34b4fbdab14 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 02:47:53 -0300 Subject: [PATCH 223/273] fix(usage): keep one email in two workspaces as two accounts (#17711) --- apps/server/src/provider/ClaudeProvider.ts | 6 + apps/server/src/provider/CodexProvider.ts | 12 +- .../src/provider/Drivers/ClaudeDriver.ts | 9 +- .../server/src/provider/claudeResetCredits.ts | 7 + apps/server/src/usage/cliproxyApi.ts | 3 + packages/contracts/src/providerUsageLimits.ts | 2 + packages/contracts/src/server.ts | 6 + .../src/server/managedProvider.test.ts | 16 +- .../src/server/managedProvider.ts | 27 ++- packages/shared/src/usageLimits.test.ts | 190 ++++++++++++++++++ packages/shared/src/usageLimits.ts | 165 ++++++++++----- 11 files changed, 382 insertions(+), 61 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 553d3d2e5fef..8bc961834f27 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -262,6 +262,11 @@ function nonEmptyProbeString(value: string): string | undefined { type ClaudeCapabilitiesProbe = { readonly email: string | undefined; + /** + * The organization UUID from the login's `.claude.json`. The driver reads it + * in the same cached probe, so it describes the same login as the rest. + */ + readonly workspaceId?: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; /** Where the CLI found an API key, when it authenticates with one. */ @@ -679,6 +684,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "authenticated", ...(capabilities.email ? { email: capabilities.email } : {}), + ...(capabilities.workspaceId ? { workspaceId: capabilities.workspaceId } : {}), ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index cb1a5513cd01..5109bb840f56 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -60,6 +60,8 @@ type CodexRateLimitsProbe = | null | undefined; readonly resetCredits: CodexResetCreditsSummary | null | undefined; + /** The ChatGPT workspace the usage belongs to, when the backend names it. */ + readonly accountId?: string | null | undefined; } | { readonly failure: string }; @@ -456,6 +458,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun snapshot: response.rateLimits, rateLimitsByLimitId: response.rateLimitsByLimitId, resetCredits: response.rateLimitResetCredits, + accountId: response.accountId, })), Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), Effect.map( @@ -539,18 +542,23 @@ const makePendingCodexProvider = ( }); }); -function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { +function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): { readonly status: Exclude; readonly auth: ServerProvider["auth"]; readonly message?: string; } { const authLabel = codexAccountAuthLabel(account.account); const authEmail = codexAccountEmail(account.account); + // One email can hold several workspaces, each with its own quota; the usage + // read names the one this login draws on. + const workspaceId = + rateLimits && "accountId" in rateLimits ? rateLimits.accountId?.trim() : undefined; const auth = { status: account.account ? ("authenticated" as const) : ("unknown" as const), ...(account.account?.type ? { type: account.account?.type } : {}), ...(authLabel ? { label: authLabel } : {}), ...(authEmail ? { email: authEmail } : {}), + ...(workspaceId ? { workspaceId } : {}), } satisfies ServerProvider["auth"]; if (account.account) { @@ -669,7 +677,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } - : accountProbeStatus(snapshot.account); + : accountProbeStatus(snapshot); const usageLimits = snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index be7872a7df28..0169eb87ba76 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -203,7 +203,14 @@ export const ClaudeDriver: ProviderDriver - probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( + Effect.all([ + probeClaudeCapabilities(effectiveConfig, processEnv, cwd), + ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath), + ]).pipe( + Effect.map(([capabilities, workspaceId]) => + capabilities && workspaceId ? { ...capabilities, workspaceId } : capabilities, + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ), }); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index e381fa7cabe2..3c4a54742082 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -189,6 +189,13 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( ); /** The CLI keeps the account record beside its settings, or in the home directory by default. */ +/** The organization the login is signed in to; one email can belong to several. */ +export const readClaudeOrganizationId = (accountConfigPath: string) => + readJson(Config, accountConfigPath).pipe( + Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined), + Effect.orElseSucceed(() => undefined), + ); + export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( configDir: string | undefined, ) { diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index c50ebb57f40e..b80ebe59261e 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -215,6 +215,9 @@ export const makeCliproxyApi = Effect.gen(function* () { id: account.id, driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), ...(account.email ? { email: account.email } : {}), + ...(account.id_token?.chatgpt_account_id?.trim() + ? { workspaceId: account.id_token.chatgpt_account_id.trim() } + : {}), }; const read = Effect.gen(function* () { if (account.provider === "claude") { diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index c692a9b7d519..0108bbb669eb 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -92,6 +92,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ email: Schema.optional(TrimmedNonEmptyString), /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */ plan: Schema.optional(TrimmedNonEmptyString), + /** The workspace the quota belongs to, when the source names one; see `ServerProviderAuth`. */ + workspaceId: Schema.optional(TrimmedNonEmptyString), usageLimits: ServerProviderUsageLimits, }); export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5855477855d1..a726f05a311c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -69,6 +69,12 @@ export const ServerProviderAuth = Schema.Struct({ canLogout: Schema.optional(Schema.Boolean), subscriptionSharing: Schema.optional(Schema.Boolean), profileId: Schema.optional(TrimmedNonEmptyString), + /** + * The workspace or organization whose quota the login draws on, such as a + * ChatGPT workspace or Claude organization id. One email can belong to + * several, each with its own quota. + */ + workspaceId: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 239508cec41c..1d73e112b815 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -591,9 +591,16 @@ describe("makeManagedServerProvider", () => { checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( Effect.map((count) => count === 1 - ? { ...refreshedSnapshot, usageLimits: probedLimits } + ? { + ...refreshedSnapshot, + auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a" }, + usageLimits: probedLimits, + } : { ...refreshedSnapshotSecond, + ...(count === 3 + ? { auth: { ...refreshedSnapshotSecond.auth, workspaceId: "ws-b" } } + : {}), usageLimits: { checkedAt: "2026-04-10T00:00:03.000Z", windows: [], @@ -640,6 +647,13 @@ describe("makeManagedServerProvider", () => { const refreshed = yield* provider.refresh; assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); + // ...and the workspace they were read for. + assert.strictEqual(refreshed.auth.workspaceId, "ws-a"); + + // A failed read for another workspace does not inherit those windows. + const switched = yield* provider.refresh; + assert.strictEqual(switched.auth.workspaceId, "ws-b"); + assert.strictEqual(switched.usageLimits?.unavailable?.reason, "probeFailed"); }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index b74788a38bad..a11734a1661b 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -155,12 +155,29 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const generation = input.enrichSnapshot ? state.enrichmentGeneration + 1 : state.enrichmentGeneration; + // A failed read keeps the last good limits only for the same account, + // and they keep the workspace they were read for. + const previous = state.snapshot.auth; + const differs = (a: string | undefined, b: string | undefined) => + a !== undefined && b !== undefined && a !== b; + // Limits read without a workspace cannot be attributed to a new one. + const switchedAccount = + differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || + (probedSnapshot.auth.workspaceId !== undefined && + probedSnapshot.auth.workspaceId !== previous.workspaceId); + const usageLimits = switchedAccount + ? probedSnapshot.usageLimits + : resolveUsageLimitsAfterProbe({ + published: state.snapshot.usageLimits, + probed: probedSnapshot.usageLimits, + }); + const workspaceId = + usageLimits === probedSnapshot.usageLimits ? undefined : state.snapshot.auth.workspaceId; const snapshot = withUsageLimits( - probedSnapshot, - resolveUsageLimitsAfterProbe({ - published: state.snapshot.usageLimits, - probed: probedSnapshot.usageLimits, - }), + workspaceId && !probedSnapshot.auth.workspaceId + ? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } } + : probedSnapshot, + usageLimits, ); return [ { snapshot, generation }, diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b2bef55cb108..668103302417 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -496,6 +496,128 @@ describe("pools", () => { expect(account?.redeem?.environmentId).toBe("env-2"); }); + it("keeps one email in two workspaces apart while merging each workspace's duplicates", () => { + const login = (instanceId: string, workspaceId: string, usedPercent: number) => + provider({ + instanceId: ProviderInstanceId.make(instanceId), + auth: { status: "authenticated", email: "same@example.com", workspaceId }, + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent }] }, + }); + const hubAccount = (id: string, workspaceId?: string) => ({ + id, + driver: ProviderDriverKind.make("codex"), + email: "same@example.com", + ...(workspaceId ? { workspaceId } : {}), + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [login("business", "ws-business", 8), login("personal", "ws-plus", 26)], + usageLimitSources: [ + // A hub that names the workspace joins it; one that does not + // cannot say which of the two it read, so it keeps its own row. + { ...source, accounts: [hubAccount("plus.json", "ws-plus"), hubAccount("x.json")] }, + ], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [login("personal", "ws-plus", 26)] }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts.map((account) => account.key)).toEqual([ + "env-a:business", + "env-a:personal", + "hub:x.json", + ]); + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(8); + // One hub file name in two workspaces stays two accounts, even without an + // email to tell them apart. + const withoutEmail = (id: string, workspaceId: string) => { + const { email: _email, ...account } = hubAccount(id, workspaceId); + return account; + }; + const sameFile = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-1")] }], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + ...laptop, + serverConfig: { + usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-2")] }], + }, + }, + ], + ]); + expect(collectLimitAccounts(sameFile).map((account) => account.key)).toEqual([ + "hub:a.json:ws-1", + "hub:a.json:ws-2", + ]); + expect(accounts[1]?.environments.map((environment) => environment.label)).toEqual([ + "Laptop", + "Desktop", + ]); + }); + + it("joins a report without a workspace to the only workspace signed in with its email", () => { + const native = provider({ + driver: claude, + auth: { status: "authenticated", email: "same@example.com", workspaceId: "org-a" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const hubAccount = { + id: "claude.json", + driver: claude, + email: "same@example.com", + usageLimits: { checkedAt, windows: [window] }, + }; + const withOther = (other: ServerProvider) => + new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native, other], + usageLimitSources: [{ ...source, accounts: [hubAccount] }], + }, + }, + ], + ]); + const unrelated = provider({ instanceId: ProviderInstanceId.make("codex-2") }); + expect(collectLimitAccounts(withOther(unrelated)).map((account) => account.key)).toEqual([ + "env-a:codex", + ]); + // A second org whose limits could not be read still makes the hub ambiguous. + const unreadable = provider({ + ...native, + instanceId: ProviderInstanceId.make("team"), + auth: { ...native.auth, workspaceId: "org-b" }, + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + expect(collectLimitAccounts(withOther(unreadable)).map((account) => account.key)).toEqual([ + "env-a:codex", + "hub:claude.json", + ]); + }); + it("names an environment once however many of its instances share the account", () => { const shared = provider({ auth: { status: "authenticated", email: "same@example.com" }, @@ -536,6 +658,45 @@ describe("pools", () => { expect(accounts[0]?.displayName).toBe("claude-team-seat"); }); + it("pools Codex windows by kind, whichever slot reported them", () => { + const codexAccount = (id: string, slots: readonly string[]) => ({ + id, + driver: ProviderDriverKind.make("codex"), + usageLimits: { + checkedAt, + windows: slots.map((slot) => ({ ...weekly, id: slot, usedPercent: 50 })), + }, + }); + const pooled = (accounts: ReturnType[]) => + collectLimitPools( + collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("env-a"), + { ...laptop, serverConfig: { usageLimitSources: [{ ...source, accounts }] } }, + ], + ]), + ), + now, + )[0]?.windows ?? []; + // A weekly-only plan reports weekly as `primary`. The pool keeps one id + // whichever account sorts first. + for (const order of [ + [codexAccount("business", ["primary"]), codexAccount("plus", ["secondary"])], + [codexAccount("plus", ["secondary"]), codexAccount("business", ["primary"])], + ]) { + const windows = pooled(order); + expect(windows.map((window) => window.id)).toEqual(["weekly"]); + expect(windows[0]?.members).toHaveLength(2); + } + // A second window of the same kind on one account stays apart, with its own id. + expect( + pooled([codexAccount("a", ["secondary"]), codexAccount("b", ["primary", "secondary"])]).map( + (window) => window.id, + ), + ).toEqual(["weekly", "weekly:secondary"]); + }); + it("pools windows by id across accounts and orders resets by when they land", () => { const input = new Map([ [ @@ -910,6 +1071,35 @@ describe("/usage-limits", () => { }); }); + it("gives a hub credit to no native row when its email is signed in to two workspaces", () => { + const workspaces = ["ws-a", "ws-b"].map((workspaceId) => + provider({ + instanceId: ProviderInstanceId.make(workspaceId), + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com", workspaceId }, + }), + ); + const hub = [ + { + ...sources[0]!, + accounts: [ + { + id: "duplicate", + driver: selected.driver, + email: "same@example.com", + usageLimits: { ...limits, resetCredits: { availableCount: 2, nextCreditId: "c" } }, + }, + ], + }, + ]; + const report = collectProviderUsageLimits(workspaces[0]!.instanceId, workspaces, hub, now); + expect(report?.accounts.map((account) => account.resetCreditInput)).toEqual([ + { instanceId: "ws-a" }, + { instanceId: "ws-b" }, + { sourceId: "hub", accountId: "duplicate", creditId: "c" }, + ]); + }); + it("redeems a native duplicate through the hub even when the native snapshot is fresher", () => { const fresher = provider({ usageLimits: { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b1211bffd54d..85c5e6e39f88 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -125,11 +125,51 @@ function accountKey( : null; } +interface AccountReport { + readonly driver: ServerProvider["driver"]; + readonly email?: string | undefined; + readonly workspaceId?: string | undefined; + readonly usageLimits?: ServerProviderUsageLimits | undefined; +} + +const nativeReport = (provider: ServerProvider): AccountReport => ({ + driver: provider.driver, + email: provider.auth.email, + workspaceId: provider.auth.workspaceId, + usageLimits: provider.usageLimits, +}); + +/** + * Keys each report by the account it draws on. One email can belong to + * several workspaces, each with its own quota, so a report that names its + * workspace is keyed by it too. One that does not (a hub, an older server) + * joins the only workspace known for its email, and gets no key when there + * are several, since it cannot say which one it read. Pass every enabled + * login, read or not, so an unreadable workspace still counts as one. + */ +function accountKeyResolver(reports: Iterable) { + const workspaces = new Map>(); + for (const report of reports) { + const key = accountKey(report.driver, report.email, report.usageLimits); + if (key && report.workspaceId) { + workspaces.set(key, (workspaces.get(key) ?? new Set()).add(report.workspaceId)); + } + } + return (report: AccountReport): string | null => { + const key = accountKey(report.driver, report.email, report.usageLimits); + if (!key) return null; + if (report.workspaceId) return `${key}:${report.workspaceId}`; + const known = workspaces.get(key); + if (!known) return key; + return known.size === 1 ? `${key}:${[...known][0]}` : null; + }; +} + /** * One subscription account as the pooled views see it, whichever way it was - * reported. Matching emails or credentials across environments name - * one account. Its quota is one bucket, so counting it twice would misstate - * what is left. + * reported. Matching emails or credentials across environments name one + * account, unless they name different workspaces. Its quota is one bucket, so + * counting it twice would misstate what is left. */ export interface LimitAccount { readonly key: string; @@ -222,26 +262,30 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl }, }); }; + const resolveKey = accountKeyResolver( + [...presentations.values()].flatMap((presentation) => [ + ...(presentation.serverConfig?.providers ?? []) + .filter((provider) => provider.enabled) + .map(nativeReport), + ...(presentation.serverConfig?.usageLimitSources ?? []).flatMap((source) => source.accounts), + ]), + ); for (const [environmentId, presentation] of presentations) { const label = presentation.entry.target.label; for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; - merge( - accountKey(provider.driver, provider.auth.email, provider.usageLimits) ?? - `${environmentId}:${provider.instanceId}`, - { - key: `${environmentId}:${provider.instanceId}`, - driver: provider.driver, - displayName: provider.displayName?.trim() || null, - email: provider.auth.email, - plan: provider.auth.label, - accentColor: provider.accentColor, - environments: [{ environmentId, label }], - sourceLabel: null, - redeem: { environmentId, input: { instanceId: provider.instanceId } }, - limits: provider.usageLimits, - }, - ); + merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, { + key: `${environmentId}:${provider.instanceId}`, + driver: provider.driver, + displayName: provider.displayName?.trim() || null, + email: provider.auth.email, + plan: provider.auth.label, + accentColor: provider.accentColor, + environments: [{ environmentId, label }], + sourceLabel: null, + redeem: { environmentId, input: { instanceId: provider.instanceId } }, + limits: provider.usageLimits, + }); } } // Every hub account, including those a native instance also knows: the hub @@ -255,31 +299,28 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl : source.label; for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; - merge( - accountKey(account.driver, account.email, account.usageLimits) ?? - `${source.id}:${account.id}`, - { - key: `${source.id}:${account.id}`, - driver: account.driver, - displayName: account.email ? null : account.id.replace(/\.json$/i, ""), - email: account.email, - plan: account.plan, - accentColor: undefined, - environments: [], - sourceLabel, - redeem: account.usageLimits.resetCredits?.nextCreditId - ? { - environmentId, - input: { - sourceId: source.id, - accountId: account.id, - creditId: account.usageLimits.resetCredits.nextCreditId, - }, - } - : null, - limits: account.usageLimits, - }, - ); + const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`; + merge(resolveKey(account) ?? key, { + key, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: account.usageLimits.resetCredits?.nextCreditId + ? { + environmentId, + input: { + sourceId: source.id, + accountId: account.id, + creditId: account.usageLimits.resetCredits.nextCreditId, + }, + } + : null, + limits: account.usageLimits, + }); } } } @@ -401,7 +442,7 @@ export function collectLimitPools( .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0]; const orderReset = (account: LimitAccount) => { const window = account.limits.windows.find( - (window) => window.kind === orderWindow?.kind && window.id === orderWindow.id, + (window) => orderWindow && poolKey(driver, window) === poolKey(driver, orderWindow), ); return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY; }; @@ -419,19 +460,35 @@ function accountSortName(account: LimitAccount): string { return (account.displayName ?? account.email ?? account.key).toLowerCase(); } +/** + * Codex ids are slots, not allowances: a weekly-only plan reports its weekly + * window as `primary`, other plans as `secondary`. Its windows pool by kind; + * other drivers' ids name distinct allowances. + */ +function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string { + return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; +} + function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { const byKey = new Map(); for (const account of accounts) { for (const window of account.limits.windows) { - const key = `${window.kind}:${window.id}`; + const pooled = poolKey(account.driver, window); + // An account with two windows of one kind keeps the second apart. + const key = byKey.get(pooled)?.some((member) => member.account === account) + ? `${window.kind}:${window.id}` + : pooled; const list = byKey.get(key); if (list) list.push({ account, window }); else byKey.set(key, [{ account, window }]); } } - const pools = [...byKey.values()].map((members): LimitPoolWindow => { + const pools = [...byKey].map(([key, members]): LimitPoolWindow => { const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; + // Pooled Codex slots take the pool's own key, so the id stays the same + // whichever account sorts first. + const id = members[0]!.account.driver === "codex" ? key : first.id; const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; // Pace compares spend against the clock, so it is judged only over the // members that have a clock; a window with no reset would otherwise @@ -458,7 +515,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L }) .sort((left, right) => left.at - right.at); return { - id: first.id, + id, kind: first.kind, label: first.label, members, @@ -633,9 +690,13 @@ export function collectProviderUsageLimits( const native = providersWithLimits(providers).filter( (provider) => provider.driver === selected.driver, ); + const resolveKey = accountKeyResolver([ + ...providers.filter((provider) => provider.enabled).map(nativeReport), + ...sources.flatMap((source) => source.accounts), + ]); const nativeAccounts = new Set( native.flatMap((provider) => { - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = resolveKey(nativeReport(provider)); return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable ? [key] : []; @@ -645,13 +706,13 @@ export function collectProviderUsageLimits( const notices: string[] = []; for (const provider of native) { if (!provider.usageLimits) continue; - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = resolveKey(nativeReport(provider)); const hubCredits = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .filter( ({ account }) => key !== null && - accountKey(account.driver, account.email, account.usageLimits) === key && + resolveKey(account) === key && account.usageLimits.resetCredits && !limitsNotice(account.usageLimits), ) @@ -698,7 +759,7 @@ export function collectProviderUsageLimits( for (const source of sources) { const matching = source.accounts.filter((account) => account.driver === selected.driver); for (const account of matching) { - const key = accountKey(account.driver, account.email, account.usageLimits); + const key = resolveKey(account); if (key && nativeAccounts.has(key)) continue; accounts.push({ id: `${source.id}:${account.id}`, From c3243f05088c3c20e9439f285fc6d7b0bfe1747d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:05:38 -0700 Subject: [PATCH 224/273] feat(pull-requests): hosts can report edit and resolve permissions per item (#17667) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestCodeTab.tsx | 10 ++- .../pullRequestEditing.logic.test.ts | 66 +++++++++++++++++++ .../pullRequest/pullRequestEditing.logic.ts | 30 +++++++-- packages/contracts/src/pullRequest.ts | 23 +++++++ 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 1339b46196b8..d5f054dadab0 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -33,7 +33,7 @@ import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings" import { useTheme } from "~/hooks/useTheme"; import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; -import { canEditPullRequestComment } from "./pullRequestEditing.logic"; +import { canEditPullRequestComment, canResolvePullRequestThread } from "./pullRequestEditing.logic"; import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, @@ -934,7 +934,7 @@ function PullRequestCodeTab({ thread={thread} workspaceRoot={detail.workspaceRoot} canReply={review.reply} - canResolve={review.resolve} + canResolve={review.resolve && canResolvePullRequestThread(detail, thread)} canReact={detail.capabilities.reactions === true} environmentId={environmentId} reference={reference} @@ -966,7 +966,11 @@ function PullRequestCodeTab({ } // A conversation on a line is made of review comments, whatever the host filed them as. canEditComment={(comment) => - canEditPullRequestComment(detail, { author: comment.author, kind: "review-comment" }) + canEditPullRequestComment(detail, { + author: comment.author, + kind: "review-comment", + canEdit: comment.canEdit, + }) } onEditComment={(commentId, body) => runThreadCommand("The comment could not be saved", () => diff --git a/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts index 1b43ff209b23..06ecec5d3295 100644 --- a/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canEditPullRequestChangeRequest, canEditPullRequestComment, + canResolvePullRequestThread, } from "./pullRequestEditing.logic"; type Subject = Pick; @@ -89,6 +90,33 @@ describe("canEditPullRequestChangeRequest", () => { ).toBe(true); }); + it("follows the host's own answer about the reader over authorship and merge access", () => { + expect( + canEditPullRequestChangeRequest( + subject({ + author: actor("someone-else"), + viewerPermissions: permissions({ editChangeRequest: true }), + }), + ), + ).toBe(true); + expect( + canEditPullRequestChangeRequest( + subject({ + viewerPermissions: permissions({ actions: ["merge"], editChangeRequest: false }), + }), + ), + ).toBe(false); + // The host can't rewrite change requests at all, so the reader's answer can't widen that. + expect( + canEditPullRequestChangeRequest( + subject({ + capabilities: capabilities({ changeRequest: false, comment: true }), + viewerPermissions: permissions({ editChangeRequest: true }), + }), + ), + ).toBe(false); + }); + it("refuses a reader who neither wrote it nor may merge it", () => { expect( canEditPullRequestChangeRequest( @@ -146,6 +174,23 @@ describe("canEditPullRequestComment", () => { ); }); + it("follows the host's own answer about a remark over authorship", () => { + expect(canEditPullRequestComment(subject(), comment({ canEdit: false }))).toBe(false); + expect( + canEditPullRequestComment( + subject(), + comment({ author: actor("someone-else"), canEdit: true }), + ), + ).toBe(true); + // The host can't rewrite remarks at all, so its per-remark answer can't widen that. + expect( + canEditPullRequestComment( + subject({ capabilities: capabilities({ changeRequest: true, comment: false }) }), + comment({ canEdit: true }), + ), + ).toBe(false); + }); + it("refuses a remark the host attributes to nobody", () => { expect(canEditPullRequestComment(subject(), comment({ author: null }))).toBe(false); }); @@ -170,3 +215,24 @@ describe("canEditPullRequestComment", () => { expect(canEditPullRequestComment(subject({ viewer: undefined }), comment())).toBe(false); }); }); + +describe("canResolvePullRequestThread", () => { + it("uses the reader's repository-wide permission where the host says nothing per thread", () => { + expect(canResolvePullRequestThread(subject(), {})).toBe(true); + expect( + canResolvePullRequestThread( + subject({ viewerPermissions: permissions({ resolve: false }) }), + {}, + ), + ).toBe(false); + }); + + it("only narrows the reader's permission with the host's answer for one thread", () => { + expect(canResolvePullRequestThread(subject(), { canResolve: false })).toBe(false); + expect( + canResolvePullRequestThread(subject({ viewerPermissions: permissions({ resolve: false }) }), { + canResolve: true, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts b/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts index a7e49f3f635f..520ff7a7ef96 100644 --- a/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts @@ -1,4 +1,8 @@ -import type { PullRequestComment, PullRequestDetail } from "@t3tools/contracts"; +import type { + PullRequestComment, + PullRequestDetail, + PullRequestReviewThread, +} from "@t3tools/contracts"; /** Only the parts of a detail either answer reads, so a caller can pass a whole detail view. */ type EditingSubject = Pick< @@ -14,12 +18,15 @@ function sameLogin(one: string | null | undefined, other: string | null | undefi /** * Whether the title and description can be rewritten from here. Beyond the host being able to at - * all, either the reader wrote the change request or they may merge it — merging is the one action - * every host here grants with write access and withholds without it, so it stands in for the - * permission none of them publishes by name. + * all, a host that reports `editChangeRequest` answers directly. Otherwise either the reader wrote + * the change request or they may merge it: merging is the one action every host here grants with + * write access and withholds without it, so it stands in for the permission they don't publish. */ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean { if (detail.capabilities.edit?.changeRequest !== true) return false; + if (detail.viewerPermissions.editChangeRequest !== undefined) { + return detail.viewerPermissions.editChangeRequest; + } return ( sameLogin(detail.viewer, detail.author?.login) || detail.viewerPermissions.actions.includes("merge") @@ -33,9 +40,22 @@ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean */ export function canEditPullRequestComment( detail: EditingSubject, - comment: Pick, + comment: Pick, ): boolean { if (detail.capabilities.edit?.comment !== true) return false; if (comment.kind !== "issue-comment" && comment.kind !== "review-comment") return false; + if (comment.canEdit !== undefined) return comment.canEdit; return sameLogin(detail.viewer, comment.author?.login); } + +/** + * Whether the reader may resolve or reopen one review thread. The host's answer for this thread + * can only narrow the reader's repository-wide `resolve` permission: a thread can't grant what the + * repository withholds. + */ +export function canResolvePullRequestThread( + detail: Pick, + thread: Pick, +): boolean { + return detail.viewerPermissions.resolve && thread.canResolve !== false; +} diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 2cd0e45cc8e0..1efbc1808d14 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -209,6 +209,12 @@ export const PullRequestComment = Schema.Struct({ reviewState: Schema.NullOr(Schema.String), /** Absent from a host with no reactions at all, which is a different thing from none on this. */ reactions: Schema.optional(Schema.Array(PullRequestReaction)), + /** + * The host's own answer to whether this reader may rewrite this remark. Absent where the host + * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both + * here and in its review thread; a host that sets this sets it on both copies alike. + */ + canEdit: Schema.optional(Schema.Boolean), }); export type PullRequestComment = typeof PullRequestComment.Type; @@ -232,6 +238,12 @@ export const PullRequestThreadComment = Schema.Struct({ editedAt: Schema.optional(Schema.NullOr(IsoDateTime)), url: Schema.NullOr(Schema.String), reactions: Schema.optional(Schema.Array(PullRequestReaction)), + /** + * The host's own answer to whether this reader may rewrite this remark. Absent where the host + * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both + * here and in its review thread; a host that sets this sets it on both copies alike. + */ + canEdit: Schema.optional(Schema.Boolean), }); export type PullRequestThreadComment = typeof PullRequestThreadComment.Type; @@ -257,6 +269,12 @@ export const PullRequestReviewThread = Schema.Struct({ commentCount: Schema.optional(NonNegativeInt), /** Opaque cursor for the next comment page. Absent once this thread is whole. */ nextCommentsCursor: Schema.optional(TrimmedNonEmptyString), + /** + * The host's own answer to whether this reader may resolve this thread. It can only narrow the + * reader's repository-wide `resolve` permission, never widen it. Absent where the host + * doesn't say per thread. + */ + canResolve: Schema.optional(Schema.Boolean), }); export type PullRequestReviewThread = typeof PullRequestReviewThread.Type; @@ -481,6 +499,11 @@ export const PullRequestViewerPermissions = Schema.Struct({ * changed on this host at all. */ labels: Schema.optional(Schema.Boolean), + /** + * This viewer may rewrite the change request's title and description. Absent where the host + * doesn't say, which leaves the page to guess from authorship and merge access. + */ + editChangeRequest: Schema.optional(Schema.Boolean), }); export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; From 9cc57680cb5f762f553acd1fa5f3efcf17d8fc67 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:08:22 -0400 Subject: [PATCH 225/273] perf(server): run Git for Windows' real git.exe, not its launcher (#17707) Co-authored-by: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.ts | 10 ++- packages/shared/src/shell.test.ts | 89 +++++++++++++++++++++++++ packages/shared/src/shell.ts | 48 ++++++++++++- 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 51c1f7b29468..53e7f6418058 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -32,6 +32,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import * as HostProcess from "@t3tools/shared/HostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; @@ -977,11 +978,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...input.env, ...trace2Monitor.env, }; + const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }; + const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv }); + // A git.cmd wrapper would need cmd.exe, which cuts multi-line commit + // messages at the first newline; leave that case to Node's lookup. + const executable = resolved.shell ? "git" : resolved.command; const child = yield* commandSpawner .spawn( - ChildProcess.make("git", commandInput.args, { + ChildProcess.make(executable, commandInput.args, { cwd: commandInput.cwd, - env: { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }, + env: spawnEnv, }), ) .pipe( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 428d0d5951f5..5f493c8044d3 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -15,6 +15,7 @@ import { listLoginShellCandidates, mergePathEntries, mergePathValues, + preferGitForWindowsBinary, readEnvironmentFromLoginShell, readEnvironmentFromWindowsShell, readPathFromLaunchctl, @@ -511,6 +512,70 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { ); }); +describe("preferGitForWindowsBinary", () => { + const files = + (...paths: Array) => + (filePath: string) => + paths.includes(filePath); + + it("runs the git.exe Git for Windows' launcher would start", () => { + // 2.56+ on x64. The PATHEXT scan returns the extension in PATHEXT's case. + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.EXE", + {}, + files( + "C:\\Program Files\\Git\\ucrt64\\bin\\git.exe", + "C:\\Program Files\\Git\\mingw64\\bin\\git.exe", + ), + ), + ).toBe("C:\\Program Files\\Git\\ucrt64\\bin\\git.exe"); + // Before 2.56, and the portable build's bin launcher. + expect( + preferGitForWindowsBinary( + "D:\\PortableGit\\bin\\git.exe", + {}, + files("D:\\PortableGit\\mingw64\\bin\\git.exe"), + ), + ).toBe("D:\\PortableGit\\mingw64\\bin\\git.exe"); + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.exe", + {}, + files("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"), + ), + ).toBe("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"); + }); + + it("keeps the launcher when MSYSTEM is set", () => { + // The real git.exe only adds its own folders to PATH when MSYSTEM is unset; + // without them a `#!/bin/sh` hook cannot start. + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.exe", + { MSYSTEM: "MINGW64" }, + files("C:\\Program Files\\Git\\mingw64\\bin\\git.exe"), + ), + ).toBe("C:\\Program Files\\Git\\cmd\\git.exe"); + }); + + it("keeps the launcher when no git.exe sits beside it", () => { + expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\git.exe", {}, () => false)).toBe( + "C:\\Program Files\\Git\\cmd\\git.exe", + ); + }); + + it("leaves other gits and other launchers alone", () => { + const everything = () => true; + expect(preferGitForWindowsBinary("C:\\Users\\me\\scoop\\shims\\git.exe", {}, everything)).toBe( + "C:\\Users\\me\\scoop\\shims\\git.exe", + ); + expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\gitk.exe", {}, everything)).toBe( + "C:\\Program Files\\Git\\cmd\\gitk.exe", + ); + }); +}); + effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { it.effect("runs Windows executables directly without a shell", () => Effect.gen(function* () { @@ -627,6 +692,30 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }).pipe(Effect.provideService(CommandResolutionCache, new Map())), ); + it.effect("keeps launcher swaps apart for environments with and without MSYSTEM", () => + Effect.gen(function* () { + let scans = 0; + const scan: SpawnExecutableResolver = () => { + scans++; + return "C:\\Git\\cmd\\git.exe"; + }; + const resolve = (env: NodeJS.ProcessEnv) => + resolveSpawnCommand("git", [], { + env: { PATH: "C:\\Git\\cmd", PATHEXT: ".EXE", ...env }, + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, scan), + ); + + yield* resolve({}); + yield* resolve({}); + expect(scans).toBe(1); + // A swap made without MSYSTEM must not reach a child that has it set. + yield* resolve({ MSYSTEM: "MINGW64" }); + expect(scans).toBe(2); + }).pipe(Effect.provideService(CommandResolutionCache, new Map())), + ); + it.effect("does not fall back to a shell for unresolved Windows commands", () => Effect.gen(function* () { const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index b24c998b1785..4d29841bfa8b 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -693,6 +693,47 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); +// Git for Windows 2.56 moved x64 builds from mingw64 to ucrt64; ARM64 builds +// live in clangarm64 and 32-bit ones in mingw32. +const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const; + +function isFileSync(filePath: string): boolean { + try { + return NodeFS.statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** + * Swaps Git for Windows' launcher (`\cmd\git.exe`, the only git its + * installer puts on PATH, or the portable build's `\bin\git.exe`) for the + * git.exe it starts. The launcher costs a second process on every git command, + * and each launch leaks a kernel token reference that slows process creation + * machine-wide until reboot. The real binary sets HOME itself, but adds its own + * folders to PATH for hooks, ssh and credential helpers only when MSYSTEM is + * unset, so the launcher stays when MSYSTEM is set. Anything else is returned + * as is. + */ +export function preferGitForWindowsBinary( + executable: string, + env: NodeJS.ProcessEnv, + isFile: (filePath: string) => boolean = isFileSync, +): string { + if (env.MSYSTEM) return executable; + const path = NodePath.win32; + if (path.basename(executable).toLowerCase() !== "git.exe") return executable; + const launcherDirectory = path.dirname(executable); + const launcherFolder = path.basename(launcherDirectory).toLowerCase(); + if (launcherFolder !== "cmd" && launcherFolder !== "bin") return executable; + const installRoot = path.dirname(launcherDirectory); + for (const build of GIT_FOR_WINDOWS_BUILDS) { + const candidate = path.join(installRoot, build, "bin", "git.exe"); + if (isFile(candidate)) return candidate; + } + return executable; +} + // Untraced because it runs before most spawns and returns at once off Windows. export const resolveSpawnCommand = Effect.fnUntraced(function* ( command: string, @@ -724,6 +765,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( platform, resolvePathEnvironmentVariable(env), resolveWindowsPathExtensions(env).join(";"), + env.MSYSTEM ? "msystem" : "", command, ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); const nowNanos = yield* Clock.currentTimeNanos; @@ -732,7 +774,11 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( if (cached !== undefined && cached.expiresAtNanos > nowNanos) { resolvedExecutable = cached.resolvedPath; } else { - resolvedExecutable = resolveExecutable(command, platform, env) ?? null; + // Cached with the scan: its file checks would otherwise run before every + // git launch. A Git upgrade that moves the real binary can fail git for up + // to the cache lifetime. + const found = resolveExecutable(command, platform, env); + resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env); if (!explicitPath && resolvedExecutable !== null) { cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); } From a7223f3969428170cc13bd7859aa59098b2f94b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:09:20 -0700 Subject: [PATCH 226/273] fix(clients): restart continuations show as a T3 Code notice, not another agent's message (#17723) Co-authored-by: Claude Opus 5.5 --- .../src/features/threads/ThreadFeed.tsx | 8 +++- apps/mobile/src/lib/threadActivity.test.ts | 20 +++++++- apps/mobile/src/lib/threadActivity.ts | 7 ++- .../src/orchestration-v2/Orchestrator.ts | 8 +++- .../RestartContinuation.test.ts | 26 ++++++++-- .../orchestration-v2/RestartContinuation.ts | 22 ++++++--- .../src/orchestration-v2/runtimeLayer.test.ts | 17 +++++++ .../src/components/chat/MessagesTimeline.tsx | 13 ++++- .../src/state/threadExecution.ts | 1 + .../client-runtime/src/userMessage.test.ts | 31 ++++++++++-- packages/client-runtime/src/userMessage.ts | 48 +++++++++++++++---- packages/contracts/src/orchestrationV2.ts | 2 + 12 files changed, 171 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a210ffcb1dc6..ee0c4711b03b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1719,11 +1719,15 @@ function renderFeedEntry( className="mb-5 items-end" {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} > - {presentation.isAutomation ? ( + {presentation.attribution === "automation" ? ( Sent by automation - ) : message.createdBy === "agent" ? ( + ) : presentation.attribution === "t3code" ? ( + + Sent by T3 Code + + ) : presentation.attribution === "agent" ? ( { expect(withoutPath?.canExpand).toBe(false); }); +it("expands a T3 Code notice to the prompt the agent got", () => { + const item = { + ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), + type: "notification", + source: { kind: "system" }, + outcome: "updated", + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + } as OrchestrationV2TurnItem; + const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + + expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); + expect(activity?.canExpand).toBe(true); + expect(activity?.getFullDetail()).toBe("Continue where you left off."); +}); + it("labels file searches with the adapter title and its search target", () => { const item: OrchestrationV2TurnItem = { ...base("file-search", "2026-06-20T00:00:03.000Z", 2), @@ -317,7 +335,7 @@ describe("buildThreadFeed", () => { expect(messageEntry).toBeDefined(); expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({ text: "Run checks", - isAutomation: true, + attribution: "automation", }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d06af828ee11..edaaf2b0fab8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -521,6 +521,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { case "monitor": return "eye"; case "background_task": + case "system": return "zap"; default: source satisfies never; @@ -814,7 +815,11 @@ function toFeedActivity( ? collectToolFilePaths(item) : null; const getFullDetail = memoizeValue(() => - readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item), + readPaths + ? readPaths.join("\n") || null + : item.type === "notification" + ? item.detail?.trim() || null + : formatItemFullDetail(row, item), ); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 475c6bea0a94..3f17a6faa55d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4674,11 +4674,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode = { type: "start_immediately" }; } } + // A restart continuation resumes the interrupted turn, so it starts + // immediately rather than queueing like other wakes. if ( command.notification !== undefined && (command.createdBy !== "agent" || (command.creationSource !== "server" && command.creationSource !== "provider") || - dispatchMode.type !== "queue_after_active") + (dispatchMode.type !== "queue_after_active" && + !( + command.restartContinuationOfRunId !== undefined && + dispatchMode.type === "start_immediately" + ))) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index dcbf4aa63728..34b3d8352c68 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -10,6 +10,7 @@ import { RunAttemptId, RunId, ThreadId, + type OrchestrationV2Command, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -606,9 +607,9 @@ it.effect("does not cancel or resume a run that completes while shutdown intent }), ); -const continuationTexts = (projection: OrchestrationV2ThreadProjection) => +const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => Effect.gen(function* () { - const texts: Array = []; + const dispatches: Array> = []; yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( Effect.provide( Layer.merge( @@ -616,7 +617,7 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => getThreadRecords: () => Effect.succeed(projection), recoverDelegatedTask: () => Effect.void, dispatch: (command) => { - if (command.type === "message.dispatch") texts.push(command.text); + if (command.type === "message.dispatch") dispatches.push(command); return Effect.succeed({} as never); }, }), @@ -624,9 +625,14 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => ), ), ); - return texts; + return dispatches; }); +const continuationTexts = (projection: OrchestrationV2ThreadProjection) => + continuationDispatches(projection).pipe( + Effect.map((dispatches) => dispatches.map((command) => command.text)), + ); + const cutMidTurn = (extra: Record = {}) => { const base = makeProjection(); return { @@ -740,6 +746,18 @@ it.effect("tells a turn cut mid-way about the background work it lost", () => }), ); +it.effect("shows a continuation as a T3 Code notice whose detail is the prompt", () => + Effect.gen(function* () { + const [resumed] = yield* continuationDispatches(cutMidTurn()); + assert.deepEqual(resumed?.notification, { + source: { kind: "system" }, + outcome: "updated", + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + }); + }), +); + it.effect( "carries the note forward when its continuation was cut before reaching the provider", () => diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 013e7a07fbc4..b7e905d0f4a9 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -153,17 +153,27 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta ); const noteText = note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); + const text = + noteText === undefined + ? CONTINUE_PROMPT + : note.settled + ? noteText + : `${noteText}\n\n${CONTINUE_PROMPT}`; yield* threads.dispatch({ type: "message.dispatch", commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), threadId: input.threadId, messageId, - text: - noteText === undefined - ? CONTINUE_PROMPT - : note.settled - ? noteText - : `${noteText}\n\n${CONTINUE_PROMPT}`, + text, + // Shown as a work log row; the prompt the agent got is its detail. + notification: { + source: { kind: "system" }, + outcome: "updated", + summary: note.settled + ? "T3 Code restarted and stopped background work" + : "T3 Code restarted and resumed this turn", + detail: text, + }, attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index bb70c880b061..3d4bf4c2a64a 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3211,6 +3211,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, messageId: MessageId.make("restart-automatic-message"), text: "Continue where you left off.", + notification: { + source: { kind: "system" as const }, + outcome: "updated" as const, + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + }, attachments: [], modelSelection, dispatchMode: { type: "start_immediately" as const }, @@ -3221,6 +3227,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const admitted = yield* orchestrator.getThreadProjection(threadId); assert.lengthOf(admitted.runs, 2); assert.equal(admitted.runs[1]?.restartContinuationOfRunId, original.id); + // The timeline shows a work log row; the prompt stays in its detail. + const continuationItems = admitted.turnItems.filter( + (item) => item.runId === admitted.runs[1]?.id, + ); + assert.isFalse(continuationItems.some((item) => item.type === "user_message")); + assert.deepInclude( + continuationItems.flatMap((item) => + item.type === "notification" ? [[item.summary, item.detail]] : [], + ), + ["T3 Code restarted and resumed this turn", "Continue where you left off."], + ); // A differently identified stale delivery still must not create another run. yield* orchestrator.dispatch({ ...command, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a3138c29044b..0518c96afc58 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2357,7 +2357,7 @@ function UserTimelineRow({ row }: { row: Extract - {userMessage.isAutomation ? ( + {userMessage.attribution === "automation" ? (

- ) : row.message.createdBy === "agent" ? ( + ) : userMessage.attribution === "t3code" ? ( +

+ Sent by T3 Code +

+ ) : userMessage.attribution === "agent" ? (

{senderThreadId ? ( { it("removes attribution from legacy scheduled prompts", () => { expect( resolveUserMessagePresentation({ role: "user", createdBy: "agent", text: legacyText }), - ).toEqual({ text: "Check for crashes.\n", isAutomation: true, scheduledTaskId: undefined }); + ).toEqual({ + text: "Check for crashes.\n", + attribution: "automation", + scheduledTaskId: undefined, + }); }); it("uses task metadata without changing the prompt", () => { @@ -20,7 +24,7 @@ describe("resolveUserMessagePresentation", () => { scheduledTaskId: ScheduledTaskId.make("task-1"), text: legacyText, }), - ).toEqual({ text: legacyText, isAutomation: true, scheduledTaskId: "task-1" }); + ).toEqual({ text: legacyText, attribution: "automation", scheduledTaskId: "task-1" }); }); it("preserves user-written and assistant-quoted schedule headers", () => { @@ -31,7 +35,7 @@ describe("resolveUserMessagePresentation", () => { ]) { expect(resolveUserMessagePresentation({ ...message, text: legacyText })).toEqual({ text: legacyText, - isAutomation: false, + attribution: null, scheduledTaskId: undefined, }); } @@ -45,12 +49,29 @@ describe("resolveUserMessagePresentation", () => { ]) { expect(resolveUserMessagePresentation({ role: "user", createdBy: "agent", text })).toEqual({ text, - isAutomation: false, + attribution: "agent", scheduledTaskId: undefined, }); } }); + it("attributes older server-sent restart continuations to T3 Code, not another agent", () => { + const text = "Note: the T3 server restarted."; + expect( + resolveUserMessagePresentation({ + role: "user", + createdBy: "agent", + creationSource: "server", + text, + }), + ).toMatchObject({ attribution: "t3code" }); + for (const creationSource of ["mcp", "provider"] as const) { + expect( + resolveUserMessagePresentation({ role: "user", createdBy: "agent", creationSource, text }), + ).toMatchObject({ attribution: "agent" }); + } + }); + it("recovers the triggering automation from older scheduler message ids", () => { for (const trigger of ["scheduled", "manual"]) { expect( @@ -62,7 +83,7 @@ describe("resolveUserMessagePresentation", () => { }), ).toEqual({ text: "Check for crashes.\n", - isAutomation: true, + attribution: "automation", scheduledTaskId: "task:daily-audit", }); } diff --git a/packages/client-runtime/src/userMessage.ts b/packages/client-runtime/src/userMessage.ts index 5acad0a44802..b292bafe0ca4 100644 --- a/packages/client-runtime/src/userMessage.ts +++ b/packages/client-runtime/src/userMessage.ts @@ -1,31 +1,59 @@ -import { type OrchestrationV2Actor, ScheduledTaskId } from "@t3tools/contracts"; +import { + type OrchestrationV2Actor, + type OrchestrationV2CreationSource, + ScheduledTaskId, +} from "@t3tools/contracts"; const LEGACY_AUTOMATION_PREFIX = /^\[Triggered by schedule task: [^\r\n]+\]\r?\n\r?\n/; const LEGACY_AUTOMATION_MESSAGE_ID = /^scheduled-task-message:(.+):\d+:(?:scheduled|manual)$/; -/** Older scheduled messages stored their attribution in the prompt itself. */ +/** + * Text and sender label for a user-role message. Older scheduled messages + * stored their attribution in the prompt itself. + */ export function resolveUserMessagePresentation(message: { readonly id?: string; readonly role: string; readonly text: string; readonly createdBy?: OrchestrationV2Actor; + readonly creationSource?: OrchestrationV2CreationSource; readonly scheduledTaskId?: ScheduledTaskId; -}) { +}): { + readonly text: string; + /** Who sent a user-role message the user did not type. */ + readonly attribution: "automation" | "agent" | "t3code" | null; + readonly scheduledTaskId: ScheduledTaskId | undefined; +} { if (message.role !== "user") { - return { text: message.text, isAutomation: false, scheduledTaskId: undefined }; + return { text: message.text, attribution: null, scheduledTaskId: undefined }; } if (message.scheduledTaskId !== undefined) { - return { text: message.text, isAutomation: true, scheduledTaskId: message.scheduledTaskId }; + return { + text: message.text, + attribution: "automation", + scheduledTaskId: message.scheduledTaskId, + }; } const legacyPrefix = LEGACY_AUTOMATION_PREFIX.exec(message.text); const legacyTaskId = legacyPrefix ? LEGACY_AUTOMATION_MESSAGE_ID.exec(message.id ?? "")?.[1] : undefined; - const isAutomation = - legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent"); + if (legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent")) { + return { + text: message.text.slice(legacyPrefix[0].length), + attribution: "automation", + scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), + }; + } return { - text: isAutomation ? message.text.slice(legacyPrefix[0].length) : message.text, - isAutomation, - scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), + text: message.text, + // Restart continuations were sent as the agent before they became notices. + attribution: + message.createdBy !== "agent" + ? null + : message.creationSource === "server" + ? "t3code" + : "agent", + scheduledTaskId: undefined, }; } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 423f8234178b..7aa8c74fd80a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1097,6 +1097,8 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( CommandNotificationSource, Schema.Struct({ kind: Schema.Literal("monitor") }), Schema.Struct({ kind: Schema.Literal("background_task") }), + /** T3 Code itself, such as a restart continuing an interrupted turn. */ + Schema.Struct({ kind: Schema.Literal("system") }), ], (kind) => Schema.Struct({ kind }), () => ({ kind: "background_task" }), From 57b3780770a829ff81e62d8dc658683bf51d5155 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:13:32 -0700 Subject: [PATCH 227/273] fix(mobile): browser picture in picture opens from the header button (#17731) Co-authored-by: Claude Opus 5.5 --- .../browser/preview-stream.browser.ts | 80 ++++++++++++------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index 07a9953cc5a2..91fa5d7a7085 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -50,7 +50,7 @@ const AGENT_CURSOR_SVG = interface Viewer { readonly stop: () => void; readonly command: (input: PreviewStreamInput) => void; - readonly togglePictureInPicture: () => Promise; + readonly togglePictureInPicture: () => void; } let activeViewer: Viewer | null = null; @@ -66,7 +66,7 @@ export function command(input: PreviewStreamInput) { } export function pictureInPicture() { - void activeViewer?.togglePictureInPicture(); + activeViewer?.togglePictureInPicture(); } export function start(configuration: PreviewStreamConfiguration) { @@ -147,6 +147,7 @@ export function start(configuration: PreviewStreamConfiguration) { }; const painter = createPreviewFramePainter(canvas, () => { if (!streaming && !stopped) reportStatus("streaming"); + sendPictureInPictureFrame(); }); const send = (message: PreviewStreamInput) => client?.send(message) ?? false; const clearInput = () => { @@ -620,10 +621,16 @@ export function start(configuration: PreviewStreamConfiguration) { const pictureInPictureSupported = interactive && typeof canvas.captureStream === "function" && + typeof CanvasCaptureMediaStreamTrack === "function" && ((document.pictureInPictureEnabled === true && typeof videoPrototype.requestPictureInPicture === "function") || typeof videoPrototype.webkitSetPresentationMode === "function"); + // Since the iOS 17 SDK, WebKit revokes the activation it grants an injected script when + // the script returns, so the native button's request must start before any await. The + // video is ready ahead of the tap: paused, and fed a frame only when it has none or + // floats, so the inline view captures nothing. let video: PresentationVideo | null = null; + let track: CanvasCaptureMediaStreamTrack | null = null; const pictureInPictureActive = () => video !== null && (document.pictureInPictureElement === video || @@ -635,16 +642,20 @@ export function start(configuration: PreviewStreamConfiguration) { active: pictureInPictureActive(), ...(detail ? { detail } : {}), }); - const ensureVideo = () => { - if (video) return video; + // Called after each paint. A frame drawn before the player attached is lost, so retry. + const sendPictureInPictureFrame = () => { + if (video && (video.readyState === HTMLMediaElement.HAVE_NOTHING || pictureInPictureActive())) { + track?.requestFrame(); + } + }; + if (pictureInPictureSupported) { + const stream = canvas.captureStream(0); + const [captured] = stream.getVideoTracks(); const element: PresentationVideo = document.createElement("video"); element.muted = true; element.playsInline = true; - element.autoplay = true; // Under the canvas at full size: WebKit pauses muted video it considers off screen. - element.srcObject = canvas.captureStream(); - // A static page sends no new frames; repaint once so the stream has one. - if (canvas.width > 0 && canvas.height > 0) canvas.getContext("2d")?.drawImage(canvas, 0, 0); + element.srcObject = stream; for (const name of [ "enterpictureinpicture", "leavepictureinpicture", @@ -656,25 +667,38 @@ export function start(configuration: PreviewStreamConfiguration) { }); } container.prepend(element); + // Covers the letterbox, where a stale frame of another shape would show through. + canvas.style.background = configuration.background; video = element; - return element; - }; - const togglePictureInPicture = async () => { - if (!pictureInPictureSupported) return; - try { - if (pictureInPictureActive()) { - if (document.pictureInPictureElement) await document.exitPictureInPicture(); - else video?.webkitSetPresentationMode?.("inline"); - return; - } - const element = ensureVideo(); - await element.play(); - if (document.pictureInPictureEnabled) await element.requestPictureInPicture(); - else element.webkitSetPresentationMode?.("picture-in-picture"); - } catch (error) { + track = captured instanceof CanvasCaptureMediaStreamTrack ? captured : null; + } + const togglePictureInPicture = () => { + const element = video; + if (!element) return; + if (pictureInPictureActive()) { + if (document.pictureInPictureElement) void document.exitPictureInPicture().catch(() => {}); + else element.webkitSetPresentationMode?.("inline"); + return; + } + if (element.readyState === HTMLMediaElement.HAVE_NOTHING) { + reportPictureInPicture("The page has not shown yet. Try again in a moment."); + return; + } + // The page may have changed since the video's last frame. WebKit captures only on a draw. + track?.requestFrame(); + canvas.getContext("2d")?.drawImage(canvas, 0, 0); + const fail = (error: unknown) => { + element.pause(); reportPictureInPicture( error instanceof Error ? error.message : "Picture in picture is unavailable.", ); + }; + if (document.pictureInPictureEnabled) { + const entering = element.requestPictureInPicture(); + void Promise.all([entering, element.play()]).catch(fail); + } else { + element.webkitSetPresentationMode?.("picture-in-picture"); + void element.play().catch(fail); } }; @@ -691,14 +715,8 @@ export function start(configuration: PreviewStreamConfiguration) { painter.stop(); client?.stop(); client = null; - if (video) { - for (const track of video.srcObject instanceof MediaStream - ? video.srcObject.getTracks() - : []) { - track.stop(); - } - video.srcObject = null; - } + track?.stop(); + if (video) video.srcObject = null; }, command: (message) => { send(message); From 65d33177b777e014c56f01851bef446785b3aa28 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 00:18:22 -0700 Subject: [PATCH 228/273] feat(source-control): GitCafe lives in @t3tools/source-control-gitcafe (#17681) Co-authored-by: Claude Opus 5.5 --- apps/server/package.json | 1 + .../SourceControlDiscovery.test.ts | 21 + .../SourceControlProviderRegistry.test.ts | 2 + .../src/sourceControl/builtInDrivers.ts | 5 + apps/web/src/components/CommandPalette.tsx | 20 +- apps/web/src/components/GitActionsControl.tsx | 12 +- apps/web/src/components/Icons.tsx | 8 + .../ThreadStatusIndicators.test.tsx | 22 + .../src/components/ThreadStatusIndicators.tsx | 23 +- .../pullRequest/pullRequestDetail.logic.ts | 2 + .../pullRequest/pullRequestLinkContextMenu.ts | 1 + .../settings/SourceControlSettings.tsx | 2 + apps/web/src/sourceControlPresentation.ts | 7 + apps/web/src/state/sourceControlActions.ts | 2 +- docs/user/source-control.md | 20 +- package.json | 2 +- packages/contracts/src/sourceControl.ts | 1 + packages/shared/src/changeRequestUrl.ts | 7 + packages/shared/src/sourceControl.ts | 31 +- packages/source-control-gitcafe/package.json | 52 ++ .../src/server/GitCafeApi.ts | 176 ++++ .../src/server/GitCafeCredentials.test.ts | 155 ++++ .../src/server/GitCafeCredentials.ts | 166 ++++ .../server/GitCafePullRequestProvider.test.ts | 751 +++++++++++++++ .../src/server/GitCafePullRequestProvider.ts | 867 ++++++++++++++++++ .../GitCafeSourceControlProvider.test.ts | 339 +++++++ .../server/GitCafeSourceControlProvider.ts | 503 ++++++++++ .../src/server/driver.ts | 20 + .../src/server/gitCafeHosts.test.ts | 46 + .../src/server/gitCafeHosts.ts | 49 + .../src/server/gitCafePullRequestJson.test.ts | 547 +++++++++++ .../src/server/gitCafePullRequestJson.ts | 578 ++++++++++++ packages/source-control-gitcafe/tsconfig.json | 7 + pnpm-lock.yaml | 34 + scripts/release-smoke.ts | 1 + 35 files changed, 4463 insertions(+), 17 deletions(-) create mode 100644 packages/source-control-gitcafe/package.json create mode 100644 packages/source-control-gitcafe/src/server/GitCafeApi.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeCredentials.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts create mode 100644 packages/source-control-gitcafe/src/server/driver.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafeHosts.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts create mode 100644 packages/source-control-gitcafe/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index 3dd02fcdcb5e..1147b5e06233 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -49,6 +49,7 @@ "@t3tools/source-control-bitbucket": "workspace:*", "@t3tools/source-control-core": "workspace:*", "@t3tools/source-control-forgejo": "workspace:*", + "@t3tools/source-control-gitcafe": "workspace:*", "@t3tools/source-control-github": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", "effect": "catalog:", diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index b4503fb7535f..8fa9201b94ca 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -25,6 +25,7 @@ import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/Git import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; @@ -53,6 +54,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(GitCafeApi.GitCafeApi)({}), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ServerSourceControlHost.layer.pipe( @@ -203,6 +205,12 @@ it.effect("reports implemented tools separately from locally available executabl auth: "unknown", account: Option.none(), }, + { + kind: "gitcafe", + status: "missing", + auth: "unknown", + account: Option.none(), + }, ], ); const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); @@ -266,6 +274,13 @@ Logged in to gitlab.com as gitlab-user ) { return Effect.succeed(processOutput("azure-user@example.com\n")); } + if (input.command === "cafe" && input.args.slice(-3).join(" ") === "auth status --json") { + return Effect.succeed( + processOutput( + encodeJson({ schemaVersion: 1, data: { host: "git.cafe", username: "cafe-user" } }), + ), + ); + } return Effect.fail( new VcsProcessSpawnError({ operation: input.operation, @@ -341,6 +356,12 @@ Logged in to gitlab.com as gitlab-user account: Option.some("forgejo-user"), detail: Option.none(), }, + { + kind: "gitcafe", + auth: "authenticated", + account: Option.some("cafe-user"), + detail: Option.none(), + }, ], ); }).pipe(Effect.provide(layerTest)); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index bfed745ca295..1fab84a6acd0 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -24,6 +24,7 @@ import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHub import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -126,6 +127,7 @@ function makeRegistry(input: { Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(GitCafeApi.GitCafeApi)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 2e830dec803e..1b891ce9259a 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -15,6 +15,9 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; +import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; @@ -32,6 +35,7 @@ const drivers = [ AzureDevOpsDriver.driver, BitbucketDriver.driver, ForgejoDriver.driver, + GitCafeDriver.driver, ]; /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ @@ -51,5 +55,6 @@ export const layer = Layer.mergeAll( AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), ForgejoCli.layer, + GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 37d80d33b7df..48c4b20a777f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -187,7 +187,14 @@ import { CommandPaletteVirtualizedResults, scrollCommandPaletteRowIntoView, } from "./CommandPaletteResults"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; +import { + AzureDevOpsIcon, + BitbucketIcon, + GitCafeIcon, + GitHubIcon, + GitLabIcon, + ForgejoIcon, +} from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; @@ -252,7 +259,7 @@ interface AddProjectEnvironmentOption { type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" >; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -278,6 +285,7 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ "forgejo", "bitbucket", "azure-devops", + "gitcafe", ]; const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ "github", @@ -285,6 +293,7 @@ const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray; case "azure-devops": return ; + case "gitcafe": + return ; case "url": return ; } @@ -392,6 +406,7 @@ function buildAddProjectRemoteSourceReadiness( forgejo: unavailable, bitbucket: unavailable, "azure-devops": unavailable, + gitcafe: unavailable, }; if (!discovery) { @@ -2093,6 +2108,7 @@ function OpenCommandPaletteDialog(props: { "bitbucket", "azure", "devops", + "gitcafe", "url", "environment", ], diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index cfe45ff24cc4..2b684cd0cd4e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -50,6 +50,7 @@ import { GitHubIcon, GitLabIcon, ForgejoIcon, + GitCafeIcon, } from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; @@ -158,7 +159,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" >; type GitActionToastId = ReturnType; @@ -239,6 +240,14 @@ const PUBLISH_PROVIDER_OPTIONS = [ pathPlaceholder: "project/repository", Icon: AzureDevOpsIcon, }, + { + value: "gitcafe", + label: "GitCafe", + description: "git.cafe", + host: "git.cafe", + pathPlaceholder: "owner/repo", + Icon: GitCafeIcon, + }, ] as const satisfies ReadonlyArray<{ readonly value: PublishProviderKind; readonly label: string; @@ -565,6 +574,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { forgejo: null, bitbucket: null, "azure-devops": null, + gitcafe: null, }; for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { if (isPublishProviderKind(provider.kind)) { diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 3e4d53d55b63..909b9c7456f3 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -693,6 +693,14 @@ export const ForgejoIcon: Icon = (props) => ( ); +// GitCafe's own mark, from https://git.cafe/favicon.svg, inset so the full-bleed square does not +// read heavier than the round and cut-out marks beside it. +export const GitCafeIcon: Icon = (props) => ( + +); + // macOS System Settings > Privacy & Security pane marks, so the SnapShot setup // step points at the same icon the user is about to look for. export const MacScreenRecordingIcon: Icon = (props) => { diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 2ad3ef64448e..3ae07b403063 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -79,4 +79,26 @@ describe("linked pull request snapshots", () => { sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, }); }); + it.each([ + ["https://git.cafe/owner/repo/pulls/42", "gitcafe"], + ["https://staging.git.cafe/owner/repo/pulls/42", "gitcafe"], + ["https://codeberg.org/owner/repo/pulls/42", "forgejo"], + ["https://gitlab.example.com/acme/web/-/merge_requests/42", "gitlab"], + ["https://github.com/owner/repo/pull/42", "github"], + ])("classifies %s as %s", (url, kind) => { + const result = linkedPullRequestSnapshotStatus({ + ...link, + url, + snapshot: { + state: "open", + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-01-03T00:00:00Z", + }, + }); + expect(result?.sourceControlProvider.kind).toBe(kind); + }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ad0c9c99a292..8368dbb0eac9 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -6,6 +6,7 @@ import { import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { resolveEnvironmentMachineKind, type EnvironmentId, @@ -129,15 +130,19 @@ export function linkedPullRequestSnapshotStatus( ): LinkedThreadPullRequestStatus | null { const snapshot = link.snapshot; if (snapshot === null) return null; - const kind = link.url.includes("/-/merge_requests/") - ? "gitlab" - : link.url.includes("/pullrequest/") - ? "azure-devops" - : link.url.includes("/pull-requests/") - ? "bitbucket" - : link.url.includes("/pulls/") - ? "forgejo" - : "github"; + // GitCafe's `/pulls/` routes look like Forgejo's, so its two hosts are told apart first. + const kind = + detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "gitcafe" + ? "gitcafe" + : link.url.includes("/-/merge_requests/") + ? "gitlab" + : link.url.includes("/pullrequest/") + ? "azure-devops" + : link.url.includes("/pull-requests/") + ? "bitbucket" + : link.url.includes("/pulls/") + ? "forgejo" + : "github"; return { pr: { number: link.number, diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 00d3aafd2d2f..083f2c8db22a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -128,6 +128,8 @@ export function pullRequestCheckoutCommand( : null; case "azure-devops": return `az repos pr checkout --id ${number}`; + case "gitcafe": + return `cafe pr checkout ${number}`; case "bitbucket": { if ( !headRepositoryNameWithOwner || diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16c4fa5d3559..6b673ad98325 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -13,6 +13,7 @@ const OPEN_ON_HOST_LABELS: Partial> = { gitlab: "Open on GitLab", forgejo: "Open on Forgejo", bitbucket: "Open on Bitbucket", + gitcafe: "Open on GitCafe", "azure-devops": "Open on Azure DevOps", }; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 978d7a57105d..68465b06d0fd 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -49,6 +49,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AzureDevOpsIcon, BitbucketIcon, + GitCafeIcon, GitHubIcon, GitIcon, GitLabIcon, @@ -83,6 +84,7 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 7a8d2cab8fe5..61a1befd6099 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -16,6 +16,7 @@ import { AzureDevOpsIcon, BitbucketIcon, ForgejoIcon, + GitCafeIcon, GitHubIcon, GitLabIcon, } from "./components/Icons"; @@ -62,6 +63,12 @@ export function getSourceControlPresentation( terminology: getChangeRequestTerminology(provider), Icon: BitbucketIcon, }; + case "gitcafe": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitCafeIcon, + }; case "change-request": return { providerName: provider?.name || presentation.providerName, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 23b44f7992bb..f760678136dc 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -275,7 +275,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; + provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 3730eb6a18d4..b0452974f1c1 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -1,7 +1,7 @@ # Source control -T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish -repositories, create pull requests, and review changes. +T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, Azure DevOps, and GitCafe to clone +and publish repositories, create pull requests, and review changes. ## Review turn changes @@ -96,6 +96,22 @@ az extension add --name azure-devops az login ``` +### GitCafe + +Install the GitCafe CLI and sign in: + +```bash +bun install -g @gitcafe/cli +cafe auth login --host https://git.cafe/api +``` + +Alternatively, set `CAFE_TOKEN` in the server's environment and restart it. The token is only +sent to the host `CAFE_HOST` names, which is `git.cafe` unless you set it. Repositories on +`staging.git.cafe` need their own login with `--host https://staging.git.cafe/api`. + +Line comments, reviewer requests, and labels are not available for GitCafe pull requests yet. +Merging a stack lands it through GitCafe, and updating a branch restacks the layers above it. + ## Start, clone, or publish a project To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or diff --git a/package.json b/package.json index ba161940d2f7..e21d895fd656 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 06934e93c740..c4d48e3ccd03 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -8,6 +8,7 @@ export const SourceControlProviderKind = Schema.Literals([ "forgejo", "azure-devops", "bitbucket", + "gitcafe", "unknown", ]); export type SourceControlProviderKind = typeof SourceControlProviderKind.Type; diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 5be8d355a166..265c31c157fb 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -48,6 +48,11 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu if (url.protocol !== "https:" && url.protocol !== "http:") return null; const host = url.hostname.toLowerCase(); + // GitCafe, only on its own two hosts: /{owner}/{repo}/pulls/{n} + if (host === "git.cafe" || host === "staging.git.cafe") { + const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + return claim(host, match); + } // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} if (isHostOf(host, "github.com", "github")) { const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); @@ -96,6 +101,8 @@ export function changeRequestUrlFor( switch (kind) { case "github": return `https://${host}/${repository}/pull/${number}`; + case "gitcafe": + return `https://${host}/${repository}/pulls/${number}`; case "forgejo": { try { const remote = new URL(remoteUrl ?? ""); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index ddfa3bdf9b46..be09c017767e 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -5,7 +5,14 @@ import type { } from "@t3tools/contracts"; export interface ChangeRequestPresentation { - readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request"; + readonly icon: + | "github" + | "gitlab" + | "forgejo" + | "azure-devops" + | "bitbucket" + | "gitcafe" + | "change-request"; readonly providerName: string; readonly shortName: string; readonly longName: string; @@ -79,6 +86,17 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", }; +const GITCAFE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "gitcafe", + providerName: "GitCafe", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "GitCafe pull request", + checkoutCommandExample: "cafe pr checkout 123", + urlExample: "https://git.cafe/owner/repo/pulls/42", +}; + const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "change-request", providerName: "source control", @@ -104,6 +122,8 @@ export function resolveChangeRequestPresentation( return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; case "bitbucket": return BITBUCKET_CHANGE_REQUEST_PRESENTATION; + case "gitcafe": + return GITCAFE_CHANGE_REQUEST_PRESENTATION; case "unknown": return GENERIC_CHANGE_REQUEST_PRESENTATION; } @@ -205,6 +225,11 @@ function isAzureDevOpsHost(host: string): boolean { ); } +/** GitCafe has no self-hosted installs: production and staging are the only hosts. */ +function isGitCafeHost(host: string): boolean { + return host === "git.cafe" || host === "staging.git.cafe"; +} + function isBitbucketHost(host: string): boolean { return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } @@ -218,6 +243,10 @@ export function detectSourceControlProviderFromRemoteUrl( } const hostname = parseHostName(host); + if (isGitCafeHost(hostname)) { + return { kind: "gitcafe", name: "GitCafe", baseUrl: toBaseUrl(hostname) }; + } + if ( hostname === "codeberg.org" || hasDnsLabel(hostname, "forgejo") || diff --git a/packages/source-control-gitcafe/package.json b/packages/source-control-gitcafe/package.json new file mode 100644 index 000000000000..eeda68c5e92d --- /dev/null +++ b/packages/source-control-gitcafe/package.json @@ -0,0 +1,52 @@ +{ + "name": "@t3tools/source-control-gitcafe", + "private": true, + "type": "module", + "exports": { + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/gitCafeHosts": { + "types": "./src/server/gitCafeHosts.ts", + "import": "./src/server/gitCafeHosts.ts" + }, + "./server/GitCafeApi": { + "types": "./src/server/GitCafeApi.ts", + "import": "./src/server/GitCafeApi.ts" + }, + "./server/GitCafeCredentials": { + "types": "./src/server/GitCafeCredentials.ts", + "import": "./src/server/GitCafeCredentials.ts" + }, + "./server/gitCafePullRequestJson": { + "types": "./src/server/gitCafePullRequestJson.ts", + "import": "./src/server/gitCafePullRequestJson.ts" + }, + "./server/GitCafePullRequestProvider": { + "types": "./src/server/GitCafePullRequestProvider.ts", + "import": "./src/server/GitCafePullRequestProvider.ts" + }, + "./server/GitCafeSourceControlProvider": { + "types": "./src/server/GitCafeSourceControlProvider.ts", + "import": "./src/server/GitCafeSourceControlProvider.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-gitcafe/src/server/GitCafeApi.ts b/packages/source-control-gitcafe/src/server/GitCafeApi.ts new file mode 100644 index 000000000000..949ece637faa --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeApi.ts @@ -0,0 +1,176 @@ +import * as Context from "effect/Context"; +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 Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +const NO_REDIRECT: RequestInit = { redirect: "manual" }; +const TIMEOUT = Duration.seconds(30); + +/** + * Why a request failed, as far as a caller needs to act on it: a missing CLI or refused token + * disables the provider, a limit pauses the host, and the rest belong to the one request. + */ +export const GitCafeApiFailure = Schema.Literals([ + "missing-tool", + "unauthenticated", + "rate-limited", + "not-found", + "failed", +]); + +export class GitCafeApiError extends Schema.TaggedError()("GitCafeApiError", { + host: Schema.String, + operation: Schema.String, + /** GitCafe's HTTP status; null when no answer arrived. */ + status: Schema.NullOr(Schema.Int), + reason: GitCafeApiFailure, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `GitCafe ${this.operation} failed on ${this.host}: ${this.detail}`; + } +} + +export interface GitCafeApiRequest { + readonly host: string; + readonly operation: string; + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Below `/api`, query string included, e.g. `/repos/owner/repo/pulls/7`. */ + readonly path: string; + readonly body?: unknown; +} + +/** GitCafe's REST API on its own hosts, authenticated with the `GitCafeCredentials` token. */ +export class GitCafeApi extends Context.Service< + GitCafeApi, + { + /** The body of a successful answer. */ + readonly request: (input: GitCafeApiRequest) => Effect.Effect; + } +>()("@t3tools/source-control-gitcafe/server/GitCafeApi") {} + +/** GitCafe's RFC 7807 error body, e.g. `{"type":"https://cafe.sh/errors/not-found",...}`. */ +const decodeProblem = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + title: Schema.optional(Schema.String), + detail: Schema.optional(Schema.String), + }), + ), +); + +const reasonForStatus = (status: number): typeof GitCafeApiFailure.Type => + status === 401 + ? "unauthenticated" + : status === 429 + ? "rate-limited" + : status === 404 + ? "not-found" + : "failed"; + +const MAX_PROBLEM_TEXT = 300; +const boundedProblemText = (text: string | undefined) => { + const line = text?.replace(/\s+/gu, " ").trim(); + if (!line) return undefined; + return line.length > MAX_PROBLEM_TEXT ? `${line.slice(0, MAX_PROBLEM_TEXT)}…` : line; +}; + +const credentialFailure = ( + error: GitCafeCredentials.GitCafeCredentialUnavailableError, +): typeof GitCafeApiFailure.Type => + error._tag === "GitCafeCliMissingError" + ? "missing-tool" + : error._tag === "GitCafeNotSignedInError" + ? "unauthenticated" + : "failed"; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + + const request = Effect.fn("GitCafeApi.request")(function* (input: GitCafeApiRequest) { + const fail = ( + fields: Pick & { cause?: unknown }, + ) => + new GitCafeApiError({ + host: input.host, + operation: input.operation, + status: fields.status, + reason: fields.reason, + detail: fields.detail, + ...(fields.cause === undefined ? {} : { cause: fields.cause }), + }); + // The host comes from a remote URL, so a token never leaves for one GitCafe does not own. + const host = GitCafeHosts.gitCafeHost(input.host); + if (host === null) + return yield* fail({ + status: null, + reason: "failed", + detail: `GitCafe lives on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}, not ${input.host}.`, + }); + yield* Effect.annotateCurrentSpan({ + "gitcafe.host": host, + "gitcafe.operation": input.operation, + "http.request.method": input.method ?? "GET", + }); + const credential = yield* credentials + .get(host) + .pipe( + Effect.mapError((cause) => + fail({ status: null, reason: credentialFailure(cause), detail: cause.message, cause }), + ), + ); + const base = HttpClientRequest.make(input.method ?? "GET")( + `https://${host}/api${input.path}`, + ).pipe( + HttpClientRequest.bearerToken(Redacted.value(credential.token)), + HttpClientRequest.setHeaders({ accept: "application/json", "user-agent": "t3code" }), + ); + const { status, text } = yield* Effect.gen(function* () { + const response = yield* httpClient + .execute( + input.body === undefined ? base : base.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)), + ) + .pipe( + // The client's own span would record the query string, which can carry branch names. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + // fetch would follow a redirect itself and keep the bearer token on a same-origin hop; + // GitCafe's API never redirects a request it serves, so a 3xx is answered as a failure. + Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT), + ); + return { status: response.status, text: yield* response.text }; + }).pipe( + Effect.timeout(TIMEOUT), + Effect.mapError((cause) => + fail({ status: null, reason: "failed", detail: "Could not reach GitCafe.", cause }), + ), + ); + yield* Effect.annotateCurrentSpan({ "http.response.status_code": status }); + if (status >= 200 && status < 300) return text; + // The source may hold a newer token than the one that was refused. + if (status === 401) yield* credentials.invalidate(host); + const problem = Option.getOrUndefined(decodeProblem(text)); + return yield* fail({ + status, + reason: reasonForStatus(status), + // GitCafe's own explanation is what the user acts on; it is kept short and single-line. + detail: + boundedProblemText(problem?.detail ?? problem?.title) ?? `GitCafe answered HTTP ${status}.`, + }); + }); + + return GitCafeApi.of({ request }); +}); + +export const layer = Layer.effect(GitCafeApi, make); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts new file mode 100644 index 000000000000..f0f8be74711f --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts @@ -0,0 +1,155 @@ +/** + * Pins `./GitCafeCredentials.ts`, a service module pass 2 creates: + * + * - `class GitCafeCredentials extends Context.Service` keyed + * `"@t3tools/source-control-gitcafe/server/GitCafeCredentials"`, with + * `get(host) => Effect` and + * `invalidate(host) => Effect`. + * - `interface GitCafeCredential { host; token: Redacted; source: "env" | "cafe" }`. + * - `Schema.TaggedError`s `GitCafeCliMissingError`, `GitCafeNotSignedInError`, + * `GitCafeCliFailedError` (union `GitCafeCredentialUnavailableError`). + * - `environmentToken(host, env): string | null` — `CAFE_TOKEN` only for the host `CAFE_HOST` + * names (git.cafe when unset). + * - `make` / `layer`, yielding `SourceControlHost` (for `process.run`) and + * `HostProcess.Environment` / `HostProcess.WorkingDirectory` from `@t3tools/shared/HostProcess`. + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; + +const processOutput = TestSourceControlHost.processOutput; + +function harness( + run: SourceControlHost.SourceControlHost["Service"]["process"]["run"], + env: NodeJS.ProcessEnv = {}, +) { + const calls: Array = []; + const layer = GitCafeCredentials.layer.pipe( + Layer.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + calls.push(input); + return run(input); + }, + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, env)), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + ); + return { layer, calls }; +} + +describe("GitCafeCredentials", () => { + it("hands CAFE_TOKEN only to the host cafe itself targets", () => { + const token = { CAFE_TOKEN: "env-token" }; + assert.strictEqual(GitCafeCredentials.environmentToken("git.cafe", token), "env-token"); + assert.isNull(GitCafeCredentials.environmentToken("staging.git.cafe", token)); + for (const CAFE_HOST of ["https://staging.git.cafe/api", "staging.git.cafe"]) { + const staging = { ...token, CAFE_HOST }; + assert.strictEqual( + GitCafeCredentials.environmentToken("staging.git.cafe", staging), + "env-token", + ); + assert.isNull(GitCafeCredentials.environmentToken("git.cafe", staging)); + } + assert.isNull(GitCafeCredentials.environmentToken("git.cafe", { CAFE_TOKEN: " " })); + }); + + it.effect("prefers CAFE_TOKEN without running cafe", () => { + const { layer, calls } = harness(() => Effect.die("cafe must not run"), { + CAFE_TOKEN: "env-token", + }); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const credential = yield* credentials.get("git.cafe"); + assert.strictEqual(Redacted.value(credential.token), "env-token"); + assert.strictEqual(credential.source, "env"); + assert.deepStrictEqual(calls, []); + }).pipe(Effect.provide(layer)); + }); + + it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => { + const { layer, calls } = harness(() => + Effect.succeed( + processOutput("protocol=https\nhost=staging.git.cafe\nusername=alice\npassword=gct_1\n"), + ), + ); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + // A token for staging is never taken from an environment that targets production. + const credential = yield* credentials.get("Staging.Git.Cafe"); + assert.strictEqual(Redacted.value(credential.token), "gct_1"); + assert.strictEqual(credential.source, "cafe"); + yield* credentials.get("staging.git.cafe"); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0]?.command, "cafe"); + assert.deepStrictEqual(calls[0]?.args, [ + "auth", + "http", + "credential", + "--host", + "https://staging.git.cafe/api", + "get", + ]); + assert.strictEqual(calls[0]?.stdin, "protocol=https\nhost=staging.git.cafe\n\n"); + // A refused token is asked for again rather than reused. + yield* credentials.invalidate("staging.git.cafe"); + yield* credentials.get("staging.git.cafe"); + assert.strictEqual(calls.length, 2); + }).pipe(Effect.provide(layer)); + }); + + it.effect.each([ + [ + "a missing CLI", + Effect.fail( + new VcsProcessSpawnError({ + operation: "GitCafeCredentials.get", + command: "cafe", + cwd: "/server", + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ), + "GitCafeCliMissingError", + ], + [ + "a helper with no login", + Effect.fail( + new VcsProcessExitError({ + operation: "GitCafeCredentials.get", + command: "cafe", + cwd: "/server", + exitCode: 1, + detail: "not signed in", + }), + ), + "GitCafeNotSignedInError", + ], + [ + "an answer without a password", + Effect.succeed(processOutput("username=alice\n")), + "GitCafeNotSignedInError", + ], + ] as const)("reports %s", ([, result, tag]) => { + const { layer } = harness(() => result); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const error = yield* credentials.get("git.cafe").pipe(Effect.flip); + assert.strictEqual(error._tag, tag); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts new file mode 100644 index 000000000000..c47e9ce1153f --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -0,0 +1,166 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + +/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */ +const TOKEN_TTL = Duration.minutes(5); +/** No credential is retried sooner, so a fresh `cafe auth login` takes effect on the next read. */ +const MISSING_TTL = Duration.seconds(10); + +export interface GitCafeCredential { + readonly host: string; + readonly token: Redacted.Redacted; + readonly source: "env" | "cafe"; +} + +/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */ +export class GitCafeCliMissingError extends Schema.TaggedError()( + "GitCafeCliMissingError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitCafe credential for ${this.host}: set CAFE_TOKEN, or install the GitCafe CLI and run \`cafe auth login\`.`; + } +} + +/** `cafe` is installed but holds no login for the host. */ +export class GitCafeNotSignedInError extends Schema.TaggedError()( + "GitCafeNotSignedInError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitCafe credential for ${this.host}: run \`cafe auth login --host https://${this.host}/api\`.`; + } +} + +/** `cafe` timed out or failed for a reason other than having no login. */ +export class GitCafeCliFailedError extends Schema.TaggedError()( + "GitCafeCliFailedError", + { host: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `The GitCafe CLI could not hand over a credential for ${this.host}. Check \`cafe auth status\` on the server.`; + } +} + +export type GitCafeCredentialUnavailableError = + | GitCafeCliMissingError + | GitCafeNotSignedInError + | GitCafeCliFailedError; + +/** Where GitCafe tokens come from. Callers ask per host and never see how the token was found. */ +export class GitCafeCredentials extends Context.Service< + GitCafeCredentials, + { + readonly get: ( + host: string, + ) => Effect.Effect; + /** Drops the held token after GitCafe refused it, so the next read asks its source again. */ + readonly invalidate: (host: string) => Effect.Effect; + } +>()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {} + +const normalizeHost = (host: string) => host.trim().toLowerCase(); + +/** + * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by + * default): a remote URL picks the host, and must not steer a production token to staging. + */ +export function environmentToken( + host: string, + env: Readonly>, +): string | null { + const configured = env.CAFE_HOST?.trim(); + const configuredHost = configured + ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host + : "git.cafe"; + if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null; + return env.CAFE_TOKEN?.trim() || null; +} + +/** A Git credential `get` answer: `key=value` lines, of which only `password` matters here. */ +function credentialPassword(output: string): string | null { + for (const line of output.split(/\r?\n/u)) { + if (line.startsWith("password=")) return line.slice("password=".length).trim() || null; + } + return null; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const { process } = yield* SourceControlHost.SourceControlHost; + const environment = yield* HostProcess.Environment; + const workingDirectory = yield* HostProcess.WorkingDirectory; + + /** + * `cafe` has no `auth token` command; its Git credential helper is the supported way to hand + * the stored login to another program, and answers over stdin/stdout so the token never + * reaches an argument list or a log. + */ + const fromCafe = (host: string) => + process + .run({ + operation: "GitCafeCredentials.get", + command: "cafe", + args: ["auth", "http", "credential", "--host", `https://${host}/api`, "get"], + cwd: workingDirectory, + stdin: `protocol=https\nhost=${host}\n\n`, + env: { CAFE_NO_UPDATE_CHECK: "1" }, + timeoutMs: 10_000, + }) + .pipe( + Effect.mapError((error) => + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? new GitCafeCliMissingError({ host }) + : error._tag === "VcsProcessExitError" + ? new GitCafeNotSignedInError({ host }) + : new GitCafeCliFailedError({ host, cause: error }), + ), + Effect.map((output) => credentialPassword(output.stdout)), + Effect.filterOrFail( + (token): token is string => token !== null, + () => new GitCafeNotSignedInError({ host }), + ), + ); + + const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (host: string) { + const fromEnv = environmentToken(host, environment); + const token = fromEnv ?? (yield* fromCafe(host)); + return { + host, + token: Redacted.make(token), + source: fromEnv !== null ? "env" : "cafe", + } satisfies GitCafeCredential; + }); + + const cache = yield* Cache.makeWith(lookup, { + capacity: 4, + // A transient failure (a timeout, a locked keyring) is asked again on the next read. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? TOKEN_TTL + : Exit.findErrorOption(exit).pipe( + Option.exists((error) => error._tag === "GitCafeCliFailedError"), + ) + ? Duration.zero + : MISSING_TTL, + }); + + return GitCafeCredentials.of({ + get: (host) => Cache.get(cache, normalizeHost(host)), + invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)), + }); +}); + +export const layer = Layer.effect(GitCafeCredentials, make); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts new file mode 100644 index 000000000000..0603dfec80ec --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -0,0 +1,751 @@ +/** + * Pins the requests `./GitCafePullRequestProvider.ts` sends for each operation this layer + * supports. Pass 2 creates: + * + * - `./GitCafeApi.ts`: service `GitCafeApi` (key `"@t3tools/source-control-gitcafe/server/GitCafeApi"`) + * with `request({ host, operation, method?, path, body? }) => Effect` + * (the response body), sending `https:///api` with the `GitCafeCredentials` bearer + * token through `HttpClient`; `make` / `layer`. Refuses any host outside `GITCAFE_HOSTS`. + * `GitCafeApiError` carries the HTTP `status` so 401/429 map to unauthenticated/rate-limited. + * - `./GitCafePullRequestProvider.ts`: `make: Effect` + * with kind `"gitcafe"`, implementing list, summary/detail, activity, diff, stack, comment, + * reply, resolve, review submit, synchronous merge, and stack land/restack fenced by + * `expectedStackHeads`. + * + * Every write carries a fresh `requestId`, which GitCafe requires. + * + * Out of scope here (layer 3): review-revision fencing, async outcome polling. + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import type * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +interface SentRequest { + readonly method: string; + /** The path below `/api`, without its query. */ + readonly path: string; + readonly query: URLSearchParams; + readonly host: string; + readonly body: unknown; + /** The `redirect` mode FetchHttpClient would hand to fetch for this request. */ + readonly redirect: string | undefined; +} + +/** + * A GitCafe host answering `"METHOD /path"` (query stripped) from `routes`; anything else is a 404 + * problem document. Every request is recorded in order. + */ +function fakeGitCafe( + routes: Record unknown)>, + options: { readonly status?: number; readonly env?: NodeJS.ProcessEnv } = {}, +) { + const sent: Array = []; + const client = HttpClient.make( + (request: HttpClientRequest.HttpClientRequest, _url, _signal, fiber) => { + const url = new URL(request.url); + assert.isTrue(url.pathname.startsWith("/api/"), url.pathname); + assert.strictEqual(request.headers.authorization, "Bearer env-token"); + const entry: SentRequest = { + method: request.method, + path: url.pathname.slice("/api".length), + query: url.searchParams, + host: url.host, + body: + request.body._tag === "Uint8Array" + ? decodeJson(new TextDecoder().decode(request.body.body)) + : undefined, + redirect: Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit)?.redirect, + }; + sent.push(entry); + const route = routes[`${entry.method} ${entry.path}`]; + const response = + route === undefined + ? new Response(encodeJson({ type: "https://cafe.sh/errors/not-found" }), { status: 404 }) + : new Response(encodeJson(typeof route === "function" ? route(entry) : route), { + status: options.status ?? 200, + }); + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + }, + ); + const layer = GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provide(TestSourceControlHost.layer()), + // An environment token, so no test runs `cafe`. + Layer.provide( + Layer.succeed(HostProcess.Environment, options.env ?? { CAFE_TOKEN: "env-token" }), + ), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ); + const writes = () => + sent.filter((request) => request.method !== "GET").map((r) => `${r.method} ${r.path}`); + return { layer, sent, writes }; +} + +const target = { cwd: "/repo", host: "git.cafe", repository: "owner/repo", number: 7 }; +const timestamp = "2026-09-12T12:00:00Z"; +const headOid = "0123456789abcdef0123456789abcdef01234567"; +const baseOid = "89abcdef0123456789abcdef0123456789abcdef"; +const actor = { + kind: "local", + actorId: "act_one", + handle: "alice", + displayName: null, + avatarUrl: "/avatars/one", +}; +const pull = { + id: "pr_one", + number: 7, + title: "Change", + state: "open", + draft: false, + sourceBranch: "feature", + targetBranch: "main", + headOid, + observedBaseOid: baseOid, + mergeRoute: "provider", + author: actor, + createdAt: timestamp, + updatedAt: timestamp, + version: 4, + description: "Body", + closedAt: null, + mergedAt: null, + sourceRepo: null, + capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true }, +}; +const root = { + id: "root", + threadId: "root", + version: 2, + author: actor, + body: "Review", + path: "file.ts", + line: 3, + side: "right", + createdAt: timestamp, + commitOid: headOid, + resolvedAt: null, + capabilities: { + edit: true, + hide: false, + unhide: false, + delete: false, + resolve: true, + unresolve: false, + }, +}; +/** + * GitCafe's stack route for pull 7, as it answers: members carry no heads, so each unmerged + * layer's head comes from its own pull. + */ +const stack = ( + members: ReadonlyArray<{ number: number; headOid: string; state?: string }>, +): Record => ({ + "GET /repos/owner/repo/pulls/7/stack": { + stack: { + id: "stack-one", + number: 3, + revision: 5, + landingBase: "main", + members: members.map((member, index) => ({ + pullRequestNumber: member.number, + title: `Layer ${member.number}`, + state: member.state ?? "open", + draft: false, + sourceBranch: `layer-${member.number}`, + position: index + 1, + })), + }, + }, + ...Object.fromEntries( + members.map((member) => [ + `GET /repos/owner/repo/pulls/${member.number}`, + { ...pull, number: member.number, headOid: member.headOid }, + ]), + ), +}); + +/** GitCafe refuses a write without an idempotency key. */ +const assertRequestId = (body: unknown) => + assert.match( + (body as { readonly requestId?: unknown } | undefined)?.requestId as string, + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + +describe("GitCafePullRequestProvider", () => { + it.effect("lists a repository's pull requests from its pulls collection", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls": { items: [pull], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement: "all", + viewer: "alice", + limit: 10, + }); + assert.deepStrictEqual( + page.items.map((item) => item.number), + [7], + ); + assert.deepStrictEqual( + server.sent.map((request) => `${request.method} ${request.path}`), + ["GET /repos/owner/repo/pulls"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("keeps a staging repository's requests and links on staging", () => { + // CAFE_TOKEN follows CAFE_HOST, so staging reads the env token only when it is the target. + const server = fakeGitCafe( + { "GET /repos/owner/repo/pulls/7": pull }, + { env: { CAFE_TOKEN: "env-token", CAFE_HOST: "staging.git.cafe" } }, + ); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const summary = yield* provider.getChangeRequestSummary!({ + ...target, + host: "staging.git.cafe", + }); + assert.strictEqual(summary.url, "https://staging.git.cafe/owner/repo/pulls/7"); + assert.isTrue(server.sent.every((request) => request.host === "staging.git.cafe")); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("refuses a repository outside GitCafe's hosts before sending anything", () => { + const server = fakeGitCafe({}); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .getChangeRequest({ ...target, host: "github.com" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.sent, []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reports a refused token as unauthenticated and a limit as rate-limited", () => + Effect.gen(function* () { + for (const [status, reason] of [ + [401, "unauthenticated"], + [429, "rate-limited"], + ] as const) { + const server = fakeGitCafe( + { "GET /repos/owner/repo/pulls/7/stack": { stack: null } }, + { + status, + }, + ); + const error = yield* GitCafePullRequestProvider.make.pipe( + Effect.flatMap((provider) => provider.getChangeRequestStack!(target)), + Effect.flip, + Effect.provide(server.layer), + ); + assert.strictEqual(error.reason, reason); + } + }), + ); + + it.effect("never lets fetch follow a redirect with the token", () => { + const server = fakeGitCafe({ "GET /repos/owner/repo/pulls/7/stack": { stack: null } }); + return GitCafePullRequestProvider.make.pipe( + Effect.flatMap((provider) => provider.getChangeRequestStack!(target)), + Effect.tap(() => + Effect.sync(() => + assert.deepStrictEqual( + server.sent.map((r) => r.redirect), + ["manual"], + ), + ), + ), + Effect.provide(server.layer), + ); + }); + + it.effect("reads the stack a pull request belongs to, with each open layer's head", () => { + const { "GET /repos/owner/repo/pulls/6": _merged, ...routes } = stack([ + { number: 6, headOid: baseOid, state: "merged" }, + { number: 7, headOid }, + ]); + const server = fakeGitCafe(routes); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getChangeRequestStack!(target); + assert.strictEqual(result?.number, 3); + // A merged layer is not fenced on, so its pull is never read. + assert.deepStrictEqual( + result?.layers.map((layer) => [layer.number, layer.headSha]), + [ + [6, undefined], + [7, headOid], + ], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads activity with the host's edit and resolve permissions", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null }, + "GET /repos/owner/repo/pulls/7/reviews": { items: [], next: null }, + "GET /repos/owner/repo/pulls/7/commits": { + items: [], + truncated: false, + next: null, + headOid, + }, + "GET /repos/owner/repo/pulls/7/reactions/": { items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const activity = yield* provider.getChangeRequestActivity(target); + assert.strictEqual(activity.comments[0]?.canEdit, true); + assert.strictEqual(activity.reviewThreads[0]?.canResolve, true); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads the viewer's permission to edit the pull request", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { + ...pull, + capabilities: { ...pull.capabilities, edit: false }, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const permissions = yield* provider.getViewerPermissions(target); + assert.strictEqual(permissions.editChangeRequest, false); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads the diff from the pull request's changed files", () => { + const file = { + path: "file.ts", + oldPath: null, + status: "modified", + additions: 1, + deletions: 1, + binary: false, + }; + const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid }; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [file], next: null }, + "POST /repos/owner/repo/pulls/7/diff-files": { + ...snapshot, + items: [ + { + ...file, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "old\n" }, + { origin: "+", content: "new\n" }, + ], + }, + ], + }, + ], + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const diff = yield* provider.getDiff(target); + assert.include(diff.patch, "-old\n+new\n"); + assert.isNull(diff.nextCursor); + // The batched hunk read is a POST, but it writes nothing. + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/diff-files"]); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("comments and replies to a thread on the pull request's comments", () => { + const written = { id: "c1", threadId: "root", version: 1 }; + const server = fakeGitCafe({ + "POST /repos/owner/repo/pulls/7/comments/": written, + "POST /repos/owner/repo/pulls/7/comments/root/replies": written, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.comment({ ...target, body: "Hello" }); + yield* provider.replyToThread({ ...target, threadId: "root", body: "Reply" }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/7/comments/", + "POST /repos/owner/repo/pulls/7/comments/root/replies", + ]); + assert.deepStrictEqual( + server.sent.map((request) => request.body), + [{ body: "Hello" }, { body: "Reply" }], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("resolves a thread through its root at the root's version", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null }, + "POST /repos/owner/repo/pulls/7/comments/root/resolve": { + id: "root", + threadId: "root", + version: 3, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.setThreadResolution({ ...target, threadId: "root", resolved: true }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/7/comments/root/resolve", + ]); + assert.deepStrictEqual(server.sent.at(-1)?.body, { expectedVersion: 2 }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("submits a review against the pull request's current head", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "POST /repos/owner/repo/pulls/7/reviews": { id: "review-1" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.submitReview({ + ...target, + verdict: "request-changes", + body: "Please fix", + comments: [], + }); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/reviews"]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + verdict: "request_changes", + body: "Please fix", + commitOid: headOid, + }); + assertRequestId(server.sent.at(-1)?.body); + + // GitCafe rejects a null body; an empty review leaves it out. + yield* provider.submitReview({ ...target, verdict: "approve", body: "", comments: [] }); + assert.notProperty(server.sent.at(-1)?.body as object, "body"); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + ["completed", true], + ["failed", false], + ] as const)("merges synchronously and reads a %s outcome", ([state, succeeds]) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state, reason: "Blocked" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .runAction({ ...target, action: "merge", mergeMethod: "squash" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, succeeds ? "Success" : "Failure"); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/merge"]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + strategy: "squash", + headOid, + expectedVersion: 4, + }); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect( + "lands a stack through a layer only while its heads are the ones the reader saw", + () => { + const layers = [ + { number: 6, headOid: baseOid }, + { number: 7, headOid }, + ]; + const server = fakeGitCafe({ + ...stack(layers), + "POST /repos/owner/repo/pulls/stacks/3/land-through": { + id: "land-1", + state: "completed", + landedCount: 2, + stepCount: 2, + stopReason: null, + error: null, + steps: [], + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const heads = layers.map((layer) => ({ number: layer.number, headSha: layer.headOid })); + const stale = yield* provider + .runAction({ + ...target, + action: "merge", + stackNumber: 3, + expectedStackHeads: [heads[0]!, { number: 7, headSha: baseOid }], + }) + .pipe(Effect.result); + assert.strictEqual(stale._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + + yield* provider.runAction({ + ...target, + action: "merge", + stackNumber: 3, + expectedStackHeads: heads, + mergeMethod: "merge", + }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/stacks/3/land-through", + ]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + expectedRevision: 5, + throughPullRequestNumber: 7, + strategy: "merge", + }); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect("restacks from a layer on update-branch", () => { + const server = fakeGitCafe({ + ...stack([{ number: 7, headOid }]), + "POST /repos/owner/repo/pulls/stacks/3/restack": { + id: "restack-1", + state: "completed", + stepCount: 1, + completedStepCount: 1, + pauseReason: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.runAction({ + ...target, + action: "update-branch", + updateMethod: "rebase", + stackNumber: 3, + expectedStackHeads: [{ number: 7, headSha: headOid }], + }); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/stacks/3/restack"]); + const { requestId, ...body } = server.sent.at(-1)?.body as { requestId: unknown }; + assertRequestId({ requestId }); + assert.deepStrictEqual(body, { expectedRevision: 5 }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + ["authored", "authors"], + ["reviewing", "reviewers"], + ] as const)("filters %s pull requests by the viewer's actor on GitCafe", ([involvement, key]) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/filter-options": { + actors: [{ actorId: "act_one", handle: "Alice" }], + }, + "GET /repos/owner/repo/pulls": { items: [pull], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement, + viewer: "alice", + limit: 10, + }); + assert.strictEqual(server.sent.at(-1)?.query.get(key), '["act_one"]'); + assert.strictEqual( + page.items[0]?.reviewRequestLogins.includes("alice"), + involvement === "reviewing", + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("lists nothing for a viewer GitCafe has no actor for", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/filter-options": { actors: [] }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement: "authored", + viewer: "alice", + limit: 10, + }); + assert.deepStrictEqual(page.items, []); + assert.deepStrictEqual( + server.sent.map((request) => request.path), + ["/repos/owner/repo/filter-options"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each(["authored", "reviewing"] as const)( + "lists %s unnarrowed when GitCafe can't say who the viewer is", + (involvement) => { + // No `filter-options` route: the fake answers 404, which is an ordinary failure. + const server = fakeGitCafe({ "GET /repos/owner/repo/pulls": { items: [pull], next: null } }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement, + viewer: "alice", + limit: 10, + }); + const listing = server.sent.find((request) => request.path === "/repos/owner/repo/pulls"); + assert.isDefined(listing); + assert.isNull(listing!.query.get(involvement === "reviewing" ? "reviewers" : "authors")); + // An unnarrowed row says nothing about who was asked to review it. + assert.notInclude(page.items[0]?.reviewRequestLogins ?? [], "alice"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect("reads line counts, open merge strategies and requested reviewers", () => { + const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid }; + const files = (from: number, count: number) => + Array.from({ length: count }, (_, index) => ({ path: `f${from + index}.ts` })); + let changePage = 0; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, reviewers: [{ actor }] }, + "GET /repos/owner/repo/pulls/7/status": { + merge: { + conflicts: "unknown", + fastForward: true, + strategies: ["merge", "squash", "rebase"], + blockers: [{ blockedStrategies: ["squash"] }, {}], + }, + checks: { pending: 0, failing: 0, total: 0 }, + }, + // Checks continue on a cursor too; a failure on a later page must not be dropped. + [`GET /repos/owner/repo/commits/${headOid}/checks`]: (request: SentRequest) => ({ + items: [ + { + name: request.query.has("after") ? "late" : "early", + status: "completed", + conclusion: request.query.has("after") ? "failure" : "success", + summary: null, + detailsUrl: null, + }, + ], + next: request.query.has("after") ? null : "checks-1", + }), + // `/changes` counts no lines, and a pull past one page continues on a cursor. + "GET /repos/owner/repo/pulls/7/changes": () => + changePage++ === 0 + ? { ...snapshot, items: files(0, 2), next: "cursor-1" } + : { ...snapshot, items: files(2, 1), next: null }, + "POST /repos/owner/repo/pulls/7/diff-files": (request: SentRequest) => ({ + ...snapshot, + items: (request.body as { paths: ReadonlyArray }).paths.map((path) => ({ + path, + additions: 2, + deletions: 1, + })), + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const detail = yield* provider.getChangeRequest(target); + assert.deepInclude(detail, { additions: 6, deletions: 3, changedFiles: 3 }); + assert.strictEqual( + server.sent.filter((request) => request.path.endsWith("/changes"))[1]?.query.get("after"), + "cursor-1", + ); + assert.deepStrictEqual(detail.mergeCapabilities, { + merge: true, + squash: false, + rebase: true, + }); + assert.deepStrictEqual( + detail.checks.map((check) => check.name), + ["early", "late"], + ); + // A requested reviewer shows before they have reviewed. + assert.deepStrictEqual( + detail.reviewers.map((reviewer) => reviewer.login), + ["alice"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("fences a native merge on the target branch's current commit", () => { + const branchOid = "fedcba9876543210fedcba9876543210fedcba98"; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, mergeRoute: "native", observedBaseOid: null }, + "GET /repos/owner/repo/commit": { oid: branchOid }, + "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state: "completed" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.runAction({ ...target, action: "merge" }); + const branchRead = server.sent.find((request) => request.path === "/repos/owner/repo/commit"); + assert.strictEqual(branchRead?.query.get("ref"), "refs/heads/main"); + assert.deepInclude(server.sent.at(-1)?.body as object, { baseOid: branchOid, headOid }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("refuses to drop a reaction whose id it cannot address", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7/reactions/": { + items: [ + { + subject: { kind: "pull_request_comment", id: "root" }, + emoji: { kind: "unicode", value: "🚀" }, + count: 1, + viewerReactionId: "../escape", + reactors: [actor], + }, + ], + next: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .setReaction({ ...target, subjectId: "root", content: "rocket", reacted: false }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("keeps GitCafe's explanation short and on one line", () => { + const server = fakeGitCafe( + { + "GET /repos/owner/repo/pulls/7": { title: "Conflict", detail: `Line\n${"x".repeat(900)}` }, + }, + { status: 409 }, + ); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const error = yield* provider.getChangeRequest(target).pipe(Effect.flip); + assert.notInclude(error.detail, "\n"); + assert.isAtMost(error.detail.length, 310); + assert.isTrue(error.detail.startsWith("Line x")); + }).pipe(Effect.provide(server.layer)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts new file mode 100644 index 000000000000..7ea6a1e6eb87 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -0,0 +1,867 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import { + NonNegativeInt, + TrimmedNonEmptyString, + type PullRequestCapabilities, + type PullRequestStackHead, +} from "@t3tools/contracts"; +import { + PullRequestProviderError, + type ProviderRepositoryRef, + type PullRequestProviderApi, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; +import * as Json from "./gitCafePullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"], + mergeMethods: ["merge", "squash", "rebase"], + // Only through a stack: GitCafe's one branch update restacks every layer above it. + updateMethods: ["rebase"], + search: true, + stacks: true, + stackActions: true, + reactions: true, + labels: false, + // Line comments go through GitCafe's review drafts, which arrive with revision fencing. + review: { + inlineComment: false, + reply: true, + resolve: true, + verdicts: ["approve", "request-changes", "comment"], + }, + reviewers: { request: false, listCandidates: false }, + edit: { changeRequest: true, comment: true }, +}; + +const PAGE_SIZE = 100; +/** Conversation pages read before the list is reported as truncated. */ +const MAX_PAGES = 10; +/** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */ +const DIFF_FILES_BATCH = 64; +/** + * Line totals cost one hunk read per batch, so a pull request larger than this reports none + * rather than reading every file. + */ +const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4; +/** Structural `/changes` pages, 500 files each, read to count a pull request's files. */ +const MAX_CHANGE_PAGES = 20; + +const chunk = (items: ReadonlyArray, size: number): Array> => + Array.from({ length: Math.ceil(items.length / size) }, (_, index) => + items.slice(index * size, (index + 1) * size), + ); + +/** Files whose hunks GitCafe can return; binary and oversized ones are listed as they are. */ +const detailablePaths = (files: ReadonlyArray) => + files.filter((file) => !file.binary && !file.tooLarge).map((file) => file.path); + +/** + * A strategy GitCafe lists, the viewer may use, and no blocker names. A blocker without + * strategies (changes requested, a lock) still leaves Merge offered, so pressing it shows + * GitCafe's own reason. + */ +const mergeStrategyOpen = (status: typeof Status.Type, strategy: string) => + status.merge.permitted !== false && + status.merge.strategies.includes(strategy) && + !(status.merge.blockers ?? []).some((blocker) => + (blocker.blockedStrategies ?? []).includes(strategy), + ); + +const Id = TrimmedNonEmptyString.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/u)); +const isId = Schema.is(Id); +const Principal = Schema.Struct({ handle: TrimmedNonEmptyString }); +const Written = Schema.Struct({ id: Id }); +const Versioned = Schema.Struct({ version: NonNegativeInt }); +const CommentDetail = Schema.Struct({ id: Id, version: NonNegativeInt }); +const Status = Schema.Struct({ + merge: Schema.Struct({ + conflicts: Schema.Literals(["unknown", "conflicting"]), + fastForward: Schema.NullOr(Schema.Boolean), + strategies: Schema.Array(Schema.String), + permitted: Schema.optional(Schema.Boolean), + blockers: Schema.optional( + Schema.Array( + Schema.Struct({ blockedStrategies: Schema.optional(Schema.Array(Schema.String)) }), + ), + ), + }), + checks: Schema.Struct({ + pending: NonNegativeInt, + failing: NonNegativeInt, + total: NonNegativeInt, + }), +}); +const FilterOptions = Schema.Struct({ + actors: Schema.Array( + Schema.Struct({ actorId: TrimmedNonEmptyString, handle: TrimmedNonEmptyString }), + ), +}); +const encodeActorIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); +const BranchCommit = Schema.Struct({ oid: TrimmedNonEmptyString }); +const Snapshot = { + version: NonNegativeInt, + headOid: TrimmedNonEmptyString, + comparisonBaseOid: TrimmedNonEmptyString, +}; +const Changes = Schema.Struct({ + ...Snapshot, + items: Json.GitCafeDiffFiles, + next: Schema.NullOr(TrimmedNonEmptyString), +}); +const DiffFiles = Schema.Struct({ ...Snapshot, items: Json.GitCafeDiffFiles }); +const DiffCursor = Schema.Struct({ + version: NonNegativeInt, + headOid: TrimmedNonEmptyString, + baseOid: TrimmedNonEmptyString, + after: TrimmedNonEmptyString, +}); +const encodeDiffCursor = Schema.encodeSync(Schema.fromJsonString(DiffCursor)); +const decodeDiffCursor = Schema.decodeEffect(Schema.fromJsonString(DiffCursor)); +const MergeOutcome = Schema.Struct({ + id: Schema.String, + state: Schema.Literals(["accepted", "completed", "failed"]), + reason: Schema.optional(Schema.String), +}); +const StackOutcome = Schema.Struct({ id: Schema.String, state: Schema.String }); +/** Where a stack operation stopped short; anything else is underway or done. */ +const FAILED_STACK_STATES = new Set(["failed", "stopped", "cancelled", "reconciliation_required"]); + +type PullRef = ProviderRepositoryRef & { readonly number: number }; +type Paged = { readonly items: ReadonlyArray; readonly next: string | null }; + +/** The heads a reader saw are still every unmerged layer the action moves. */ +function stackHeadsCurrent( + stack: Json.GitCafeStack, + input: { readonly number: number; readonly merge: boolean }, + expected: ReadonlyArray, +): boolean { + const members = stack.members.toSorted((a, b) => a.position - b.position); + const index = members.findIndex((member) => member.pullRequestNumber === input.number); + if (index === -1) return false; + const moved = (input.merge ? members.slice(0, index + 1) : members).filter( + (member) => member.state !== "merged", + ); + return ( + moved.length === expected.length && + moved.every( + (member) => + member.headOid !== undefined && + expected.some( + (head) => head.number === member.pullRequestNumber && head.headSha === member.headOid, + ), + ) + ); +} + +/** Lays the detailed files over the structural page, keeping its order and anything omitted. */ +const withDetails = ( + files: ReadonlyArray, + details: ReadonlyArray, +) => { + const byPath = new Map(details.map((file) => [file.path, file])); + return files.map((file) => byPath.get(file.path) ?? file); +}; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const api = yield* GitCafeApi.GitCafeApi; + + const failure = (operation: string, detail: string, cause?: unknown) => + new PullRequestProviderError({ + provider: "gitcafe", + operation, + reason: "failed", + detail, + ...(cause === undefined ? {} : { cause }), + }); + + /** The repository's API root, refused before anything is sent for a host GitCafe is not on. */ + const repositoryPath = ( + operation: string, + input: ProviderRepositoryRef, + ): Effect.Effect => + GitCafeHosts.isGitCafeHost(input.host) && + GitCafeHosts.gitCafeRepositoryPath(input.repository) === input.repository + ? Effect.succeed(`/repos/${input.repository}`) + : Effect.fail( + failure( + operation, + `GitCafe serves owner/name repositories on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}.`, + ), + ); + + const send = ( + operation: string, + input: Pick, + request: Omit, + ) => + api.request({ ...request, host: input.host, operation }).pipe( + Effect.mapError( + (error) => + new PullRequestProviderError({ + provider: "gitcafe", + operation, + reason: error.reason, + detail: error.detail, + cause: error, + }), + ), + ); + + const read = >( + operation: string, + input: Pick, + request: Omit, + schema: S, + ) => + send(operation, input, request).pipe( + Effect.flatMap((raw) => + Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError((cause) => + failure(operation, "GitCafe returned an unreadable response.", cause), + ), + ), + ), + ); + + const pullPath = (operation: string, input: PullRef) => + repositoryPath(operation, input).pipe(Effect.map((base) => `${base}/pulls/${input.number}`)); + + const readPull = (operation: string, input: PullRef) => + pullPath(operation, input).pipe( + Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)), + ); + + /** Every page of a cursor list, up to `MAX_PAGES`; `next` stays set when more remained. */ + const readPages = , unknown, never, never>>( + operation: string, + input: Pick, + path: string, + schema: S, + ) => + Effect.gen(function* () { + const items: Array = []; + let next: string | null = null; + for (let page = 0; page < MAX_PAGES; page++) { + const query = new URLSearchParams({ limit: String(PAGE_SIZE) }); + if (next !== null) query.set("after", next); + const batch: S["Type"] = yield* read( + operation, + input, + { path: `${path}?${query}` }, + schema, + ); + items.push(...batch.items); + const previous: string | null = next; + next = batch.next; + if (next === null || next === previous || batch.items.length === 0) break; + } + return { items, next }; + }); + + const readComments = (operation: string, input: PullRef) => + pullPath(operation, input).pipe( + Effect.flatMap((path) => + readPages(operation, input, `${path}/comments`, Json.GitCafeComments), + ), + ); + + /** + * The stack a pull request belongs to, with each unmerged layer's head. GitCafe's stack lists no + * heads, and they are what a land or restack is fenced on, so each open layer's pull is read. + */ + const readStack = Effect.fn("GitCafePullRequestProvider.readStack")(function* ( + operation: string, + input: PullRef, + ) { + const path = yield* pullPath(operation, input); + const { stack } = yield* read( + operation, + input, + { path: `${path}/stack` }, + Json.GitCafeStackEnvelope, + ); + if (stack === null) return null; + const members = yield* Effect.forEach( + stack.members, + (member) => + member.state === "merged" + ? Effect.succeed(member) + : readPull(operation, { ...input, number: member.pullRequestNumber }).pipe( + Effect.map((pull) => + pull.headOid == null ? member : { ...member, headOid: pull.headOid }, + ), + ), + { concurrency: 4 }, + ); + return { ...stack, members }; + }); + + /** One `/diff-files` read, refused if GitCafe answers from another revision than the page. */ + const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* ( + input: PullRef, + revision: { readonly version: number; readonly headOid: string; readonly baseOid: string }, + paths: ReadonlyArray, + ) { + if (paths.length === 0) return []; + const path = yield* pullPath("getDiff", input); + const files = yield* read( + "getDiff", + input, + { + method: "POST", + path: `${path}/diff-files`, + body: { paths, expectedVersion: revision.version }, + }, + DiffFiles, + ); + if ( + files.version !== revision.version || + files.headOid !== revision.headOid || + files.comparisonBaseOid !== revision.baseOid + ) + return yield* failure( + "getDiff", + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + return files.items; + }); + + const runStackAction = Effect.fn("GitCafePullRequestProvider.runStackAction")(function* ( + input: Parameters[0] & { readonly stackNumber: number }, + ) { + const land = input.action === "merge"; + const operation = land ? "landStackThrough" : "restackStack"; + if (!land && input.action !== "update-branch") + return yield* failure(operation, `GitCafe cannot ${input.action} a stack.`); + const stack = yield* readStack(operation, input); + if ( + stack === null || + stack.number !== input.stackNumber || + input.expectedStackHeads === undefined || + !stackHeadsCurrent(stack, { number: input.number, merge: land }, input.expectedStackHeads) + ) + return yield* failure( + operation, + "The stack changed since it was read. Refresh it and try again.", + ); + const base = yield* repositoryPath(operation, input); + const outcome = yield* read( + operation, + input, + { + method: "POST", + path: `${base}/pulls/stacks/${stack.number}/${land ? "land-through" : "restack"}`, + body: land + ? { + expectedRevision: stack.revision, + requestId: yield* randomUuidV4, + throughPullRequestNumber: input.number, + strategy: input.mergeMethod ?? "merge", + } + : { expectedRevision: stack.revision, requestId: yield* randomUuidV4 }, + }, + StackOutcome, + ); + if (FAILED_STACK_STATES.has(outcome.state)) + return yield* failure(operation, `GitCafe's stack operation ${outcome.state}.`); + }); + + /** + * Added and deleted line totals and the file count. `/changes` lists files without counting + * lines, so the counts come from batched hunk reads, and a pull request past the cap reports + * none rather than paying for every file. + */ + const readLineStats = Effect.fn("GitCafePullRequestProvider.readLineStats")(function* ( + input: PullRef, + version: number, + ) { + const path = yield* pullPath("getChangeRequest", input); + const files: Array = []; + let revision: { version: number; headOid: string; baseOid: string } | undefined; + let after: string | null = null; + for (let page = 0; page < MAX_CHANGE_PAGES; page++) { + const query = new URLSearchParams({ expectedVersion: String(version), limit: "500" }); + if (after !== null) query.set("after", after); + const batch: typeof Changes.Type = yield* read( + "getChangeRequest", + input, + { path: `${path}/changes?${query}` }, + Changes, + ); + revision ??= { + version: batch.version, + headOid: batch.headOid, + baseOid: batch.comparisonBaseOid, + }; + files.push(...batch.items); + const previous: string | null = after; + after = batch.next; + if (after === null || after === previous || batch.items.length === 0) break; + } + // A hunk read that loses a race with a push leaves the counts at zero rather than dropping + // the file count with them. + const counted = + files.some((file) => file.additions !== undefined) || + files.length > LINE_STATS_MAX_FILES || + revision === undefined + ? files + : (yield* Effect.forEach( + chunk(detailablePaths(files), DIFF_FILES_BATCH), + (paths) => readDiffFiles(input, revision, paths), + { concurrency: 4 }, + ).pipe(Effect.orElseSucceed(() => [[]]))).flat(); + return { + changedFiles: files.length, + additions: counted.reduce((total, file) => total + (file.additions ?? 0), 0), + deletions: counted.reduce((total, file) => total + (file.deletions ?? 0), 0), + }; + }); + + const merge = Effect.fn("GitCafePullRequestProvider.merge")(function* ( + input: Parameters[0], + ) { + const pull = yield* readPull("merge", input); + if (pull.state === "merged") return; + if (pull.mergeRoute === "unsupported") + return yield* failure("merge", "This GitCafe pull request has no supported merge route."); + const base = yield* repositoryPath("merge", input); + // GitCafe observes the base only for merges it routes to the provider; a native merge is + // fenced on the target branch's current commit. + const baseOid = + pull.mergeRoute === "provider" + ? pull.observedBaseOid + : (yield* read( + "merge", + input, + { + path: `${base}/commit?ref=${encodeURIComponent(`refs/heads/${pull.targetBranch}`)}`, + }, + BranchCommit, + )).oid; + if (pull.headOid === null || baseOid == null) + return yield* failure( + "merge", + "GitCafe has not reported the pull request's head and base yet. Refresh and try again.", + ); + const path = yield* pullPath("merge", input); + const outcome = yield* read( + "merge", + input, + { + method: "POST", + path: `${path}/merge`, + body: { + requestId: yield* randomUuidV4, + expectedVersion: pull.version, + headOid: pull.headOid, + baseOid, + strategy: input.mergeMethod ?? "merge", + }, + }, + MergeOutcome, + ); + // An accepted merge is GitCafe's to finish; the pull request's state reports when it has. + if (outcome.state === "failed") + return yield* failure("merge", outcome.reason ?? "GitCafe could not merge the pull request."); + }); + + const provider: PullRequestProviderApi = { + kind: "gitcafe", + capabilities: CAPABILITIES, + getViewer: (input) => + read( + "getViewer", + { host: input.host ?? "git.cafe" }, + { path: "/auth/principal" }, + Principal, + ).pipe(Effect.map((principal) => principal.handle)), + listChangeRequests: Effect.fn("GitCafePullRequestProvider.listChangeRequests")( + function* (input) { + const base = yield* repositoryPath("listChangeRequests", input); + const involved = input.involvement === "authored" || input.involvement === "reviewing"; + let actorId: string | undefined; + if (involved) { + // A host narrows as far as it can: when the actor list can't be read, the listing comes + // back unnarrowed and the page's own involvement filter does the rest. A refused token + // or a rate limit still fails, since the service acts on those for the whole host. + const options = yield* read( + "listChangeRequests", + input, + { path: `${base}/filter-options` }, + FilterOptions, + ).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PullRequestProviderError: (error) => + error.reason === "unauthenticated" || error.reason === "rate-limited" + ? Effect.fail(error) + : Effect.succeed(Option.none()), + }), + ); + if (Option.isSome(options)) { + actorId = options.value.actors.find( + (actor) => actor.handle.toLowerCase() === input.viewer.toLowerCase(), + )?.actorId; + // A viewer GitCafe knows no actor for has authored and been asked to review nothing. + if (actorId === undefined) return { items: [], truncated: false, continues: false }; + } + } + const limit = Math.max(1, input.limit); + const items: Array = []; + let next: string | null = null; + do { + const query = new URLSearchParams({ + limit: String(Math.min(PAGE_SIZE, limit - items.length)), + sort: "newest", + }); + if (input.state !== "all") query.set("state", input.state); + if (input.query?.trim()) query.set("q", input.query.trim()); + if (actorId !== undefined) + query.set( + input.involvement === "reviewing" ? "reviewers" : "authors", + encodeActorIds([actorId]), + ); + if (next !== null) query.set("after", next); + const page: typeof Json.GitCafePulls.Type = yield* read( + "listChangeRequests", + input, + { path: `${base}/pulls?${query}` }, + Json.GitCafePulls, + ); + items.push(...page.items); + next = page.items.length === 0 ? null : page.next; + } while (next !== null && items.length < limit); + return { + items: items.slice(0, limit).map((pull) => { + const item = Json.toChangeRequest(pull, input); + // GitCafe filtered by reviewer, so the viewer is one even where the pull omits it. An + // unnarrowed listing (no actor to filter by) says nothing about who was asked. + return input.involvement === "reviewing" && actorId !== undefined + ? { + ...item, + reviewRequestLogins: [...new Set([...item.reviewRequestLogins, input.viewer])], + } + : item; + }), + truncated: next !== null, + continues: false, + }; + }, + ), + getChangeRequestSummary: (input) => + readPull("getChangeRequestSummary", input).pipe( + Effect.map((pull) => ({ + ...Json.toChangeRequest(pull, input), + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + })), + ), + getChangeRequest: Effect.fn("GitCafePullRequestProvider.getChangeRequest")(function* (input) { + const pull = yield* readPull("getChangeRequest", input); + const path = yield* pullPath("getChangeRequest", input); + const base = yield* repositoryPath("getChangeRequest", input); + const [status, checks, changes] = yield* Effect.all( + [ + read("getChangeRequest", input, { path: `${path}/status` }, Status), + pull.headOid === null + ? Effect.succeed(null) + : readPages( + "getChangeRequest", + input, + `${base}/commits/${encodeURIComponent(pull.headOid)}/checks`, + Json.GitCafeChecks, + ), + pull.headOid === null + ? Effect.succeed(null) + : readLineStats(input, pull.version).pipe(Effect.orElseSucceed(() => null)), + ], + { concurrency: 3 }, + ); + const permissions = Json.toViewerPermissions(pull); + return { + ...Json.toChangeRequest(pull, input), + headSha: pull.headOid, + body: pull.description ?? "", + ...(pull.sourceRepo === null + ? {} + : { headRepositoryNameWithOwner: `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` }), + additions: changes?.additions ?? 0, + deletions: changes?.deletions ?? 0, + changedFiles: changes?.changedFiles ?? 0, + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + reviewers: Json.toReviewers(pull, input.host), + checks: checks === null ? [] : Json.toChecks(checks), + ...(status.checks.total === 0 + ? {} + : { + checksState: + status.checks.failing > 0 + ? ("failing" as const) + : status.checks.pending > 0 + ? ("pending" as const) + : ("passing" as const), + }), + mergeability: + status.merge.conflicts === "conflicting" + ? ("conflicting" as const) + : ("unknown" as const), + baseComparison: + status.merge.fastForward === null + ? ("unknown" as const) + : status.merge.fastForward + ? ("up-to-date" as const) + : ("behind" as const), + mergeCapabilities: { + merge: mergeStrategyOpen(status, "merge"), + squash: mergeStrategyOpen(status, "squash"), + rebase: mergeStrategyOpen(status, "rebase"), + }, + viewerPermissions: + status.merge.permitted === false + ? { ...permissions, actions: permissions.actions.filter((a) => a !== "merge") } + : permissions, + }; + }), + getChangeRequestStack: (input) => + readStack("getChangeRequestStack", input).pipe( + Effect.map((stack) => (stack === null ? null : Json.toStack(stack, input))), + ), + getChangeRequestActivity: Effect.fn("GitCafePullRequestProvider.getChangeRequestActivity")( + function* (input) { + const pull = yield* readPull("getChangeRequestActivity", input); + const path = yield* pullPath("getChangeRequestActivity", input); + const operation = "getChangeRequestActivity"; + const [comments, reviews, commits, reactions] = yield* Effect.all( + [ + readComments(operation, input), + readPages(operation, input, `${path}/reviews`, Json.GitCafeReviews), + pull.headOid === null + ? Effect.succeed({ items: [], next: null }) + : readPages(operation, input, `${path}/commits`, Json.GitCafeCommits), + // Reactions decorate the conversation; a failed read leaves them out, not it. + readPages(operation, input, `${path}/reactions/`, Json.GitCafeReactions).pipe( + Effect.orElseSucceed(() => undefined), + ), + ], + { concurrency: 4 }, + ); + return Json.toActivity({ + comments, + reviews, + commits: { ...commits, truncated: commits.next !== null, headOid: pull.headOid ?? "" }, + host: input.host, + ...(pull.headOid === null ? {} : { headOid: pull.headOid }), + ...(reactions === undefined ? {} : { reactions }), + }); + }, + ), + getViewerPermissions: (input) => + readPull("getViewerPermissions", input).pipe(Effect.map(Json.toViewerPermissions)), + getDiff: Effect.fn("GitCafePullRequestProvider.getDiff")(function* (input) { + if (input.commit !== undefined) + return yield* failure("getDiff", "GitCafe commit diffs are not supported yet."); + const cursor = + input.cursor === undefined + ? null + : yield* decodeDiffCursor(input.cursor).pipe( + Effect.mapError((cause) => failure("getDiff", "Invalid GitCafe diff cursor.", cause)), + ); + const version = cursor?.version ?? (yield* readPull("getDiff", input)).version; + const path = yield* pullPath("getDiff", input); + const query = new URLSearchParams({ + expectedVersion: String(version), + limit: String(DIFF_FILES_BATCH), + }); + if (cursor !== null) query.set("after", cursor.after); + const page = yield* read("getDiff", input, { path: `${path}/changes?${query}` }, Changes); + if ( + page.version !== version || + (cursor !== null && + (cursor.headOid !== page.headOid || cursor.baseOid !== page.comparisonBaseOid)) + ) + return yield* failure( + "getDiff", + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; + const details = yield* readDiffFiles(input, revision, detailablePaths(page.items)); + return { + ...Json.toDiff({ items: withDetails(page.items, details) }), + nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + }; + }), + runAction: Effect.fn("GitCafePullRequestProvider.runAction")(function* (input) { + if (input.stackNumber !== undefined) + return yield* runStackAction({ ...input, stackNumber: input.stackNumber }); + switch (input.action) { + case "merge": + return yield* merge(input); + case "ready": + case "draft": + case "close": + case "reopen": { + const pull = yield* readPull(input.action, input); + const path = yield* pullPath(input.action, input); + yield* send(input.action, input, { + method: "POST", + path: `${path}/${input.action}`, + body: { expectedVersion: pull.version }, + }); + return; + } + default: + return yield* failure(input.action, `GitCafe cannot ${input.action} from T3 Code.`); + } + }), + updateChangeRequest: Effect.fn("GitCafePullRequestProvider.updateChangeRequest")( + function* (input) { + const pull = yield* readPull("updateChangeRequest", input); + const path = yield* pullPath("updateChangeRequest", input); + yield* read( + "updateChangeRequest", + input, + { + method: "PATCH", + path, + body: { + expectedVersion: pull.version, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }, + }, + Versioned, + ); + }, + ), + comment: (input) => + pullPath("comment", input).pipe( + Effect.flatMap((path) => + read( + "comment", + input, + { method: "POST", path: `${path}/comments/`, body: { body: input.body } }, + Written, + ), + ), + Effect.asVoid, + ), + updateComment: Effect.fn("GitCafePullRequestProvider.updateComment")(function* (input) { + if (!isId(input.commentId)) + return yield* failure("updateComment", "GitCafe comment ids are opaque slugs."); + const path = `${yield* pullPath("updateComment", input)}/comments/${input.commentId}`; + const detail = yield* read("updateComment", input, { path: `${path}/detail` }, CommentDetail); + yield* read( + "updateComment", + input, + { method: "PATCH", path, body: { body: input.body, expectedVersion: detail.version } }, + Written, + ); + }), + submitReview: Effect.fn("GitCafePullRequestProvider.submitReview")(function* (input) { + if (input.comments.length > 0) + return yield* failure("submitReview", "GitCafe line comments are not supported yet."); + const pull = yield* readPull("submitReview", input); + if (pull.headOid === null) + return yield* failure("submitReview", "GitCafe has not reported the pull request's head."); + const path = yield* pullPath("submitReview", input); + yield* read( + "submitReview", + input, + { + method: "POST", + path: `${path}/reviews`, + body: { + verdict: input.verdict === "request-changes" ? "request_changes" : input.verdict, + ...(input.body.length === 0 ? {} : { body: input.body }), + commitOid: pull.headOid, + expectedVersion: pull.version, + requestId: yield* randomUuidV4, + }, + }, + Written, + ); + }), + listReviewerCandidates: () => + Effect.fail( + failure("listReviewerCandidates", "GitCafe reviewer requests are not supported."), + ), + setReviewerRequest: () => + Effect.fail(failure("setReviewerRequest", "GitCafe reviewer requests are not supported.")), + replyToThread: Effect.fn("GitCafePullRequestProvider.replyToThread")(function* (input) { + if (!isId(input.threadId)) + return yield* failure("replyToThread", "GitCafe thread ids are opaque slugs."); + const path = yield* pullPath("replyToThread", input); + yield* read( + "replyToThread", + input, + { + method: "POST", + path: `${path}/comments/${input.threadId}/replies`, + body: { body: input.body }, + }, + Written, + ); + }), + setReaction: Effect.fn("GitCafePullRequestProvider.setReaction")(function* (input) { + const path = `${yield* pullPath("setReaction", input)}/reactions/`; + const subject = + input.subjectId === undefined + ? { kind: "pull_request" as const, id: (yield* readPull("setReaction", input)).id } + : { kind: "pull_request_comment" as const, id: input.subjectId }; + const reactions = yield* readPages("setReaction", input, path, Json.GitCafeReactions); + const mine = reactions.items.find( + (item) => + item.subject.kind === subject.kind && + item.subject.id === subject.id && + item.emoji.kind === "unicode" && + Json.reactionContent(item.emoji.value) === input.content, + )?.viewerReactionId; + if (input.reacted === (mine != null)) return; + if (input.reacted) { + yield* send("setReaction", input, { + method: "POST", + path, + body: { subject, emoji: { kind: "unicode", value: Json.REACTION_EMOJI[input.content] } }, + }); + return; + } + if (mine == null) return; + if (!isId(mine)) + return yield* failure("setReaction", "GitCafe returned an unreadable reaction id."); + yield* send("setReaction", input, { method: "DELETE", path: `${path}${mine}` }); + }), + setThreadResolution: Effect.fn("GitCafePullRequestProvider.setThreadResolution")( + function* (input) { + const comments = yield* readComments("setThreadResolution", input); + const root = comments.items.find( + (comment) => comment.id === input.threadId && comment.threadId === input.threadId, + ); + if (root === undefined || root.version === undefined || !isId(root.id)) + return yield* failure("setThreadResolution", "GitCafe did not return the thread root."); + if (input.resolved === (root.resolvedAt !== null)) return; + const path = yield* pullPath("setThreadResolution", input); + yield* read( + "setThreadResolution", + input, + { + method: "POST", + path: `${path}/comments/${root.id}/${input.resolved ? "resolve" : "unresolve"}`, + body: { expectedVersion: root.version }, + }, + Written, + ); + }, + ), + }; + return provider; +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts new file mode 100644 index 000000000000..aaaf5d0e90ff --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -0,0 +1,339 @@ +/** + * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates: + * + * - `discovery: SourceControlCliDiscoverySpec` — kind `"gitcafe"`, executable `"cafe"`, + * `authArgs` ending in `["auth", "status", "--json"]` against `https://git.cafe/api`, and a + * `parseAuth` that reads cafe's `{ schemaVersion: 1, data: { host, username } }` answer and its + * `{ schemaVersion: 1, error: { code, status, message } }` failure envelope. + * - `make: Effect` with + * kind `"gitcafe"`, resolving the repository and host from the checkout's remote. + * + * Uses `./GitCafeApi.ts` and `./GitCafeCredentials.ts` as pinned in the sibling tests. + */ +import { assert, describe, it } from "@effect/vitest"; +import { GitCommandError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const authAnswer = (input: { stdout?: unknown; stderr?: unknown; exitCode?: number }) => ({ + stdout: input.stdout === undefined ? "" : encodeJson(input.stdout), + stderr: input.stderr === undefined ? "" : encodeJson(input.stderr), + exitCode: ChildProcessSpawner.ExitCode(input.exitCode ?? 0), +}); +const failure = (code: string, status: number | null) => ({ + schemaVersion: 1, + error: { code, status, message: `Failure ${code}` }, +}); + +describe("GitCafeSourceControlProvider", () => { + it("probes the cafe CLI against production", () => { + const { discovery } = GitCafeSourceControlProvider; + assert.strictEqual(discovery.kind, "gitcafe"); + assert.strictEqual(discovery.executable, "cafe"); + assert.deepStrictEqual(discovery.authArgs.slice(0, 2), ["--host", "https://git.cafe/api"]); + assert.deepStrictEqual(discovery.authArgs.slice(-3), ["auth", "status", "--json"]); + }); + + it("reads a signed-in account and keeps transient failures apart from sign-out", () => { + const { parseAuth } = GitCafeSourceControlProvider.discovery; + const signedIn = parseAuth( + authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }), + ); + assert.strictEqual(signedIn.status, "authenticated"); + assert.deepStrictEqual(signedIn.account, Option.some("alice")); + assert.strictEqual( + parseAuth(authAnswer({ stderr: failure("AUTHENTICATION_REQUIRED", 401), exitCode: 1 })) + .status, + "unauthenticated", + ); + for (const [code, status] of [ + ["FORBIDDEN", 403], + ["NETWORK_ERROR", null], + ] as const) { + assert.strictEqual( + parseAuth(authAnswer({ stderr: failure(code, status), exitCode: 1 })).status, + "unknown", + ); + } + }); + + it.effect.each([ + ["https://staging.git.cafe/team/project.git", "staging.git.cafe"], + ["ssh@git.cafe:team/project.git", "git.cafe"], + ] as const)("resolves the repository from the remote %s", ([remoteUrl, host]) => { + const urls: Array = []; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), + ), + ); + }); + const context = { + provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: `https://${host}` }, + remoteName: "origin", + remoteUrl, + }; + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + assert.strictEqual(provider.kind, "gitcafe"); + assert.strictEqual(yield* provider.getDefaultBranch({ cwd: "/repo", context }), "trunk"); + assert.deepStrictEqual( + yield* provider.getRepositoryCloneUrls({ cwd: "/repo", context, repository: remoteUrl }), + { + nameWithOwner: "team/project", + url: `https://${host}/team/project`, + sshUrl: `ssh@${host}:team/project.git`, + }, + ); + assert.deepStrictEqual(urls, [ + `https://${host}/api/repos/team/project`, + `https://${host}/api/repos/team/project`, + ]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(TestSourceControlHost.layer()), + Layer.provide( + Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }), + ), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); + + it.effect( + "reads a bare owner/name on git.cafe without a remote, and no other host's pulls", + () => { + const urls: Array = []; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), + ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + // The checkout has no remote at all, as when a repository is about to be created. + assert.deepStrictEqual( + yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: "team/project" }), + { + nameWithOwner: "team/project", + url: "https://git.cafe/team/project", + sshUrl: "ssh@git.cafe:team/project.git", + }, + ); + const foreign = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://example.com/team/project/pulls/42", + }) + .pipe(Effect.flip); + assert.strictEqual(foreign._tag, "SourceControlProviderError"); + assert.deepStrictEqual(urls, ["https://git.cafe/api/repos/team/project"]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }, + ); + + it.effect.each([ + ["no context", undefined, "https://git.cafe/fork/project.git"], + ["an HTTPS remote", "https://git.cafe/team/project.git", "https://git.cafe/fork/project.git"], + ["an SSH remote", "ssh@git.cafe:team/project.git", "ssh@git.cafe:fork/project.git"], + ["an ssh:// remote", "ssh://ssh@git.cafe/team/project.git", "ssh@git.cafe:fork/project.git"], + [ + "an HTTPS remote with a user", + "https://alice@git.cafe/team/project.git", + "https://git.cafe/fork/project.git", + ], + ] as const)("checks a fork out over the transport of %s", ([, remoteUrl, expected]) => { + const remotes: Array = []; + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson({ + number: 7, + title: "Change", + state: "open", + draft: false, + sourceBranch: "feature", + targetBranch: "main", + headOid: null, + updatedAt: null, + sourceRepo: { owner: "fork", name: "project" }, + closedAt: null, + mergedAt: null, + }), + ), + ), + ), + ); + const context = + remoteUrl === undefined + ? undefined + : { + provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: "https://git.cafe" }, + remoteName: "origin", + remoteUrl, + }; + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + yield* provider.checkoutChangeRequest({ + cwd: "/repo", + reference: "https://git.cafe/team/project/pulls/7", + ...(context === undefined ? {} : { context }), + }); + assert.deepStrictEqual(remotes, [expected]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + ensureRemote: (input) => { + remotes.push(input.url); + return Effect.succeed("gitcafe"); + }, + listLocalBranchNames: () => Effect.succeed([]), + fetchRemoteBranch: () => Effect.void, + setBranchUpstream: () => Effect.void, + switchRef: () => Effect.succeed({ refName: null }), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); + + it.effect("waits for a created repository's admission before handing out its URLs", () => { + const urls: Array = []; + const states = ["running", "complete"]; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson({ + repoId: "repo_1", + state: states.shift(), + owner: "team", + name: "project", + }), + ), + ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + const created = yield* provider + .createRepository({ cwd: "/repo", repository: "team/project", visibility: "private" }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(yield* Fiber.join(created), { + nameWithOwner: "team/project", + url: "https://git.cafe/team/project", + sshUrl: "ssh@git.cafe:team/project.git", + }); + assert.deepStrictEqual(urls, [ + "https://git.cafe/api/orgs/team/admissions/repo_1", + "https://git.cafe/api/orgs/team/admissions/repo_1", + ]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + process: { + run: () => + Effect.succeed( + TestSourceControlHost.processOutput( + encodeJson({ + schemaVersion: 1, + data: { + resource: { + repoId: "repo_1", + state: "running", + owner: "team", + name: "project", + }, + }, + }), + ), + ), + }, + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts new file mode 100644 index 000000000000..5d55bd72bac3 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -0,0 +1,503 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; +import { + PositiveInt, + SourceControlProviderError, + TrimmedNonEmptyString, + type ChangeRequest, +} from "@t3tools/contracts"; +import { + providerAuth, + type SourceControlCliDiscoverySpec, +} from "@t3tools/source-control-core/server/discovery"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +/** Discovery reports the production account; repository operations pick their own host. */ +const AUTH_HOST = "git.cafe"; +/** `cafe repo create --clone` waits this long, polling at this pace, for admission to settle. */ +const ADMISSION_TIMEOUT = "30 seconds"; +const ADMISSION_POLL_INTERVAL = "500 millis"; +const cliArgs = (host: string) => [ + "--host", + `https://${host}/api`, + "--no-input", + "--no-update-check", +]; + +const decodeAuth = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + schemaVersion: Schema.Literal(1), + data: Schema.Struct({ host: Schema.String, username: TrimmedNonEmptyString }), + }), + ), +); +/** `cafe`'s failure envelope, which it prints on its own line after any raw problem body. */ +const decodeFailure = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + schemaVersion: Schema.Literal(1), + error: Schema.Struct({ + code: Schema.String, + message: Schema.String, + status: Schema.NullOr(Schema.Finite), + }), + }), + ), +); +const cliFailure = (output: string) => + [output.trim(), ...output.split(/\r?\n/u).toReversed()] + .map((line) => decodeFailure(line.trim())) + .find(Result.isSuccess)?.success.error; + +export const discovery = { + type: "cli", + kind: "gitcafe", + label: "GitCafe", + executable: "cafe", + versionArgs: ["--version"], + authArgs: [...cliArgs(AUTH_HOST), "auth", "status", "--json"], + parseAuth: (input) => { + const signedIn = decodeAuth(input.stdout.trim()); + if (input.exitCode === 0 && Result.isSuccess(signedIn)) + return providerAuth({ + status: "authenticated", + host: AUTH_HOST, + account: signedIn.success.data.username, + }); + const failure = cliFailure(input.stderr) ?? cliFailure(input.stdout); + if (failure) + return providerAuth({ + // Only a refusal says the login is gone; a forbidden or offline answer says nothing. + status: + failure.code === "AUTHENTICATION_REQUIRED" || failure.status === 401 + ? "unauthenticated" + : "unknown", + host: AUTH_HOST, + detail: failure.message, + }); + return providerAuth({ + status: "unknown", + host: AUTH_HOST, + detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`, + }); + }, + installHint: `Install the GitCafe CLI with \`bun install -g @gitcafe/cli\`, then run \`cafe auth login --host https://${AUTH_HOST}/api\`, or set CAFE_TOKEN on the server.`, +} satisfies SourceControlCliDiscoverySpec; + +const Repository = Schema.Struct({ + name: TrimmedNonEmptyString, + defaultBranch: Schema.NullOr(TrimmedNonEmptyString), +}); +const Pull = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + state: Schema.Literals(["open", "closed", "merged"]), + draft: Schema.Boolean, + sourceBranch: TrimmedNonEmptyString, + targetBranch: TrimmedNonEmptyString, + headOid: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.OptionFromNullOr(Schema.DateTimeUtcFromString), +}); +const PullDetail = Schema.Struct({ + ...Pull.fields, + sourceRepo: Schema.NullOr( + Schema.Struct({ owner: TrimmedNonEmptyString, name: TrimmedNonEmptyString }), + ), + closedAt: Schema.NullOr(Schema.String), + mergedAt: Schema.NullOr(Schema.String), +}); +const Pulls = Schema.Struct({ items: Schema.Array(Pull), next: Schema.NullOr(Schema.String) }); +const RepositoryAdmission = Schema.Struct({ + repoId: TrimmedNonEmptyString, + state: Schema.String, + owner: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +const CreatedRepository = Schema.Struct({ + data: Schema.Struct({ resource: RepositoryAdmission }), +}); +const decodeCreatedRepository = Schema.decodeEffect(Schema.fromJsonString(CreatedRepository)); +const encodeBranches = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +interface Target { + readonly host: GitCafeHosts.GitCafeHost; + readonly repository: string; +} + +const cloneUrls = (target: Target) => ({ + nameWithOwner: target.repository, + url: `https://${target.host}/${target.repository}`, + sshUrl: `ssh@${target.host}:${target.repository}.git`, +}); + +/** An scp-like `user@host:path` or `ssh://` remote; HTTPS remotes may carry userinfo too. */ +const isSshRemote = (remoteUrl: string | undefined) => + remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl)); + +const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?#].*)?$/u; + +export const make = Effect.gen(function* () { + const api = yield* GitCafeApi.GitCafeApi; + const host = yield* SourceControlHost.SourceControlHost; + + const error = (operation: string, cwd: string, detail: string, cause?: unknown) => + new SourceControlProviderError({ + provider: "gitcafe", + operation, + cwd, + detail, + ...(cause === undefined ? {} : { cause }), + }); + const read = >( + operation: string, + cwd: string, + request: Omit, + schema: S, + ) => + api.request({ ...request, operation }).pipe( + Effect.mapError((cause) => error(operation, cwd, cause.detail, cause)), + Effect.flatMap((raw) => + Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError((cause) => + error(operation, cwd, "GitCafe returned an unreadable response.", cause), + ), + ), + ), + ); + const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray) => + host.process + .run({ + operation: `GitCafeSourceControlProvider.${operation}`, + command: "cafe", + cwd, + args: [...cliArgs(target.host), ...args], + env: { CAFE_OUTPUT: "json" }, + timeoutMs: 30_000, + }) + .pipe( + Effect.mapError((cause) => + error( + operation, + cwd, + (cause._tag === "VcsProcessExitError" && cliFailure(cause.detail)?.message) || + "GitCafe CLI command failed.", + cause, + ), + ), + ); + + /** The repository a call names, falling back to the checkout's own GitCafe remote. */ + const resolveTarget = Effect.fn("GitCafeSourceControlProvider.resolveTarget")(function* ( + operation: string, + input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }, + ) { + const named = + input.repository === undefined ? null : GitCafeHosts.parseGitCafeRemote(input.repository); + if (named) return named; + // A bare owner/name only asks the checkout which GitCafe host it is on, so a checkout with no + // remote (a repository being created) still resolves, on git.cafe. + const readRemote = host.git + .resolvePrimaryRemoteName(input.cwd) + .pipe( + Effect.flatMap((remote) => host.git.readConfigValue(input.cwd, `remote.${remote}.url`)), + ); + const remoteUrl = + input.context?.remoteUrl ?? + (input.repository === undefined + ? yield* readRemote.pipe( + Effect.mapError((cause) => + error(operation, input.cwd, "Could not read the GitCafe remote.", cause), + ), + ) + : yield* readRemote.pipe(Effect.orElseSucceed(() => null))); + const remote = remoteUrl ? GitCafeHosts.parseGitCafeRemote(remoteUrl) : null; + if (input.repository === undefined) { + if (remote) return remote; + return yield* error(operation, input.cwd, "No GitCafe repository remote was found."); + } + // A bare owner/name lives on the checkout's own GitCafe host. + const repository = GitCafeHosts.gitCafeRepositoryPath(input.repository); + if (repository !== null) + return { host: remote?.host ?? AUTH_HOST, repository } satisfies Target; + return yield* error( + operation, + input.cwd, + "A GitCafe repository is named owner/name or by its git.cafe URL.", + ); + }); + + const getPull = Effect.fn("GitCafeSourceControlProvider.getPull")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) { + const match = pullUrlPattern.exec(input.reference.trim()); + const urlHost = match?.[1] ? GitCafeHosts.gitCafeHost(match[1]) : null; + const target: Target = + urlHost && match?.[2] + ? { host: urlHost, repository: match[2] } + : yield* resolveTarget("getChangeRequest", input); + // A pull request URL on another host is not a GitCafe pull request number. + const number = match + ? urlHost + ? match[3] + : undefined + : /^#?([1-9]\d*)$/u.exec(input.reference.trim())?.[1]; + if (number === undefined) + return yield* error( + "getChangeRequest", + input.cwd, + "A GitCafe pull request is a number or a git.cafe pull request URL.", + ); + const pull = yield* read( + "getChangeRequest", + input.cwd, + { host: target.host, path: `/repos/${target.repository}/pulls/${number}` }, + PullDetail, + ); + const owner = target.repository.split("/")[0] ?? null; + return { + provider: "gitcafe", + number: pull.number, + title: pull.title, + url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + baseRefName: pull.targetBranch, + headRefName: pull.sourceBranch, + ...(pull.headOid ? { headSha: pull.headOid } : {}), + state: pull.state, + ...(pull.draft ? { isDraft: true } : {}), + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + updatedAt: pull.updatedAt, + isCrossRepository: pull.sourceRepo !== null, + headRepositoryNameWithOwner: pull.sourceRepo + ? `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` + : target.repository, + headRepositoryOwnerLogin: pull.sourceRepo?.owner ?? owner, + } satisfies ChangeRequest; + }); + + return SourceControlProvider.SourceControlProvider.of({ + kind: "gitcafe", + repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null, + getChangeRequest: getPull, + listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")( + function* (input) { + const target = yield* resolveTarget("listChangeRequests", input); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const branch = SourceControlProvider.sourceBranch(input); + const limit = input.limit ?? 20; + const items: Array = []; + let after: string | null = null; + do { + const query = new URLSearchParams({ + sourceBranches: encodeBranches([branch]), + limit: String(Math.min(limit, 100)), + sort: "newest", + }); + if (input.state !== "all") query.set("state", input.state); + if (after !== null) query.set("after", after); + const page: typeof Pulls.Type = yield* read( + "listChangeRequests", + input.cwd, + { host: target.host, path: `/repos/${target.repository}/pulls?${query}` }, + Pulls, + ); + for (const pull of page.items) { + if (pull.sourceBranch !== branch || items.length >= limit) continue; + // List rows omit fork identity, so only the branch's own rows are read in full. + const detail = yield* getPull({ + cwd: input.cwd, + reference: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + }); + if ( + (!source?.repository || detail.headRepositoryNameWithOwner === source.repository) && + (!source?.owner || detail.headRepositoryOwnerLogin === source.owner) + ) + items.push(detail); + } + after = page.items.length === 0 || page.next === after ? null : page.next; + } while (after !== null && items.length < limit); + return items; + }, + ), + createChangeRequest: Effect.fn("GitCafeSourceControlProvider.createChangeRequest")( + function* (input) { + const target = yield* resolveTarget("createChangeRequest", { + ...input, + ...(input.target?.repository ? { repository: input.target.repository } : {}), + }); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const sourceRepository = + source?.repository ?? + (source?.owner + ? `${source.owner}/${target.repository.split("/")[1]}` + : target.repository); + const branch = SourceControlProvider.sourceBranch(input); + yield* cafe("createChangeRequest", input.cwd, target, [ + "pr", + "create", + "--json", + "--repo", + target.repository, + "--head", + sourceRepository === target.repository ? branch : `${sourceRepository}:${branch}`, + "--base", + input.target?.refName ?? input.baseRefName, + "--title", + input.title, + "--body-file", + input.bodyFile, + ]); + }, + ), + getRepositoryCloneUrls: Effect.fn("GitCafeSourceControlProvider.getRepositoryCloneUrls")( + function* (input) { + const target = yield* resolveTarget("getRepositoryCloneUrls", input); + yield* read( + "getRepositoryCloneUrls", + input.cwd, + { host: target.host, path: `/repos/${target.repository}` }, + Repository, + ); + return cloneUrls(target); + }, + ), + createRepository: Effect.fn("GitCafeSourceControlProvider.createRepository")(function* (input) { + const target = yield* resolveTarget("createRepository", input); + const [owner = "", name = ""] = target.repository.split("/"); + const output = yield* cafe("createRepository", input.cwd, target, [ + "repo", + "create", + name, + "--org", + owner, + "--visibility", + input.visibility, + "--json", + ]); + const created = yield* decodeCreatedRepository(output.stdout).pipe( + Effect.mapError((cause) => + error("createRepository", input.cwd, "GitCafe returned an unreadable response.", cause), + ), + ); + // GitCafe admits a repository asynchronously: `cafe repo create` answers while admission is + // still running, so wait for it to settle the way `cafe repo create --clone` does. + const settled = (state: string) => state === "complete" || state === "blocked"; + const pending = created.data.resource; + const resource = settled(pending.state) + ? pending + : yield* read( + "createRepository", + input.cwd, + { + host: target.host, + path: `/orgs/${pending.owner}/admissions/${pending.repoId}`, + }, + RepositoryAdmission, + ).pipe( + Effect.repeat({ + until: (admission) => settled(admission.state), + schedule: Schedule.spaced(ADMISSION_POLL_INTERVAL), + }), + Effect.timeoutOption(ADMISSION_TIMEOUT), + Effect.map(Option.getOrElse(() => pending)), + ); + // Anything short of complete is not yet usable. + if (resource.state !== "complete") + return yield* error( + "createRepository", + input.cwd, + `GitCafe repository ${target.repository} is ${resource.state}. Check it on GitCafe before retrying.`, + ); + return cloneUrls({ host: target.host, repository: `${resource.owner}/${resource.name}` }); + }), + getDefaultBranch: Effect.fn("GitCafeSourceControlProvider.getDefaultBranch")(function* (input) { + const target = yield* resolveTarget("getDefaultBranch", input); + const repository = yield* read( + "getDefaultBranch", + input.cwd, + { host: target.host, path: `/repos/${target.repository}` }, + Repository, + ); + return repository.defaultBranch; + }), + // `cafe` has no forced checkout, so the branch is fetched and switched to with git. + checkoutChangeRequest: Effect.fn("GitCafeSourceControlProvider.checkoutChangeRequest")( + function* (input) { + const pull = yield* getPull(input); + const pullHost = new URL(pull.url).host; + const sourceRepository = pull.headRepositoryNameWithOwner; + if (!sourceRepository) + return yield* error( + "checkoutChangeRequest", + input.cwd, + "GitCafe did not name the pull request's source repository.", + ); + const contextRemote = input.context + ? GitCafeHosts.parseGitCafeRemote(input.context.remoteUrl) + : null; + const git = host.git; + yield* Effect.gen(function* () { + const remoteName = + input.context && + contextRemote?.repository === sourceRepository && + contextRemote.host === pullHost + ? input.context.remoteName + : yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: "gitcafe", + // SSH only when the checkout already reaches GitCafe over SSH; HTTPS otherwise. + url: isSshRemote(input.context?.remoteUrl) + ? `ssh@${pullHost}:${sourceRepository}.git` + : `https://${pullHost}/${sourceRepository}.git`, + }); + const localBranch = pull.isCrossRepository + ? `pr-${pull.number}/${pull.headRefName}` + : pull.headRefName; + const branches = yield* git.listLocalBranchNames(input.cwd); + const remoteBranch = { cwd: input.cwd, remoteName, remoteBranch: pull.headRefName }; + if (input.force !== true && !branches.includes(localBranch)) + yield* git.fetchRemoteBranch({ ...remoteBranch, localBranch }); + else yield* git.fetchRemoteTrackingBranch(remoteBranch); + // `checkout -B` moves even the checked-out branch, and refuses to overwrite local edits. + if (input.force === true) + yield* git.execute({ + operation: "GitCafeSourceControlProvider.checkoutChangeRequest", + cwd: input.cwd, + args: [ + "checkout", + "-B", + localBranch, + `refs/remotes/${remoteName}/${pull.headRefName}`, + "--", + ], + }); + yield* git.setBranchUpstream({ ...remoteBranch, branch: localBranch }); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + }).pipe( + Effect.mapError((cause) => + error( + "checkoutChangeRequest", + input.cwd, + "Could not check out the GitCafe pull request.", + cause, + ), + ), + ); + }, + ), + }); +}); diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts new file mode 100644 index 000000000000..467614c2ec1c --- /dev/null +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -0,0 +1,20 @@ +/** + * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull + * requests through GitCafe's REST API on git.cafe and staging.git.cafe. + * + * @module source-control-gitcafe/server/driver + */ +import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; +import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: "gitcafe", + make: Effect.all({ + sourceControl: GitCafeSourceControlProvider.make, + discovery: Effect.succeed(GitCafeSourceControlProvider.discovery), + pullRequests: GitCafePullRequestProvider.make, + }), +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts new file mode 100644 index 000000000000..a828a99f6ff1 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts @@ -0,0 +1,46 @@ +/** + * Pins `./gitCafeHosts.ts`, which pass 2 creates as a plain module (no service): + * + * - `GITCAFE_HOSTS: readonly ["git.cafe", "staging.git.cafe"]` — the only hosts GitCafe lives on. + * - `isGitCafeHost(host: string): boolean` — case-insensitive membership in `GITCAFE_HOSTS`. + * - `parseGitCafeRemote(url: string): { readonly host: GitCafeHost; readonly repository: string } | null` + * — the host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. + */ +import { assert, describe, it } from "@effect/vitest"; + +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +describe("gitCafeHosts", () => { + it("knows exactly the production and staging hosts", () => { + assert.deepStrictEqual([...GitCafeHosts.GITCAFE_HOSTS], ["git.cafe", "staging.git.cafe"]); + assert.isTrue(GitCafeHosts.isGitCafeHost("Git.Cafe")); + assert.isTrue(GitCafeHosts.isGitCafeHost("staging.git.cafe")); + for (const host of ["cafe", "evil.git.cafe", "git.cafe.example.com", "github.com"]) { + assert.isFalse(GitCafeHosts.isGitCafeHost(host), host); + } + }); + + it.each([ + ["https://git.cafe/team/project.git", "git.cafe"], + ["https://git.cafe/team/project", "git.cafe"], + ["https://staging.git.cafe/team/project.git/", "staging.git.cafe"], + ["ssh@git.cafe:team/project.git", "git.cafe"], + ["git@staging.git.cafe:team/project.git", "staging.git.cafe"], + ["ssh://git@git.cafe/team/project.git", "git.cafe"], + ])("reads the host and repository of %s", (url, host) => { + assert.deepStrictEqual(GitCafeHosts.parseGitCafeRemote(url), { + host, + repository: "team/project", + }); + }); + + it.each([ + "https://github.com/team/project.git", + "https://evil.git.cafe/team/project.git", + "https://git.cafe/team/nested/project.git", + "https://git.cafe/team", + "team/project", + ])("is not a GitCafe repository: %s", (url) => { + assert.isNull(GitCafeHosts.parseGitCafeRemote(url)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts new file mode 100644 index 000000000000..a04b759b2d5f --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts @@ -0,0 +1,49 @@ +/** + * The hosts GitCafe lives on, and how a remote URL names one of its repositories. GitCafe has no + * self-hosted installs, so the list is fixed rather than discovered. + * + * @module source-control-gitcafe/server/gitCafeHosts + */ + +export const GITCAFE_HOSTS = ["git.cafe", "staging.git.cafe"] as const; +export type GitCafeHost = (typeof GITCAFE_HOSTS)[number]; + +/** The host as GitCafe spells it, or null for any host GitCafe does not live on. */ +export function gitCafeHost(host: string): GitCafeHost | null { + const normalized = host.trim().toLowerCase(); + return GITCAFE_HOSTS.find((candidate) => candidate === normalized) ?? null; +} + +export function isGitCafeHost(host: string): boolean { + return gitCafeHost(host) !== null; +} + +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const REMOTE_PATTERNS = [ + /^https?:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/iu, + /^ssh:\/\/[^@/]+@([^/:]+)(?::\d+)?\/(.+)$/iu, + /^[^@/:]+@([^/:]+):(.+)$/u, +]; + +/** An `owner/name` repository path, without the `.git` suffix a clone URL carries. */ +export function gitCafeRepositoryPath(path: string): string | null { + const repository = path + .trim() + .replace(/\/+$/u, "") + .replace(/\.git$/u, ""); + return REPOSITORY.test(repository) ? repository : null; +} + +/** The host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. */ +export function parseGitCafeRemote( + url: string, +): { readonly host: GitCafeHost; readonly repository: string } | null { + for (const pattern of REMOTE_PATTERNS) { + const match = pattern.exec(url.trim()); + if (!match?.[1] || !match[2]) continue; + const host = gitCafeHost(match[1]); + const repository = gitCafeRepositoryPath(match[2]); + return host && repository ? { host, repository } : null; + } + return null; +} diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts new file mode 100644 index 000000000000..41d578cdf1e0 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -0,0 +1,547 @@ +/** + * Pins `./gitCafePullRequestJson.ts`, pure decoding of GitCafe's REST JSON into the neutral + * `Provider*` shapes. Pass 2 creates these exports: + * + * Schemas (REST bodies as GitCafe sends them): + * - `GitCafePull` (list row), `GitCafePullDetail` (row + description, closedAt, mergedAt, + * sourceRepo, lockedAt?, required `capabilities: { comment, review, merge, edit, moderate }`), + * - `GitCafeComments`, `GitCafeReviews`, `GitCafeCommits`, `GitCafeReactions` + * (cursor envelopes `{ items, next }`; commits also `{ truncated, headOid }`), + * - `GitCafeStackEnvelope` (`{ stack: null | { id, number, revision, landingBase, members } }`, + * members with an optional `headOid`), `GitCafeDiff` (`{ items, truncated? }`), + * `GitCafeChecks` (`{ items, next }`). + * + * Conversions (all take the repository host, so staging stays on staging): + * - `toChangeRequest(pull, { repository, host }): ProviderChangeRequest` + * - `toViewerPermissions(detail): PullRequestViewerPermissions` — fills `editChangeRequest`. + * - `toActivity({ comments, reviews, commits, reactions?, headOid?, host }): + * ProviderChangeRequestActivity` — fills `canEdit` on both copies of a remark and `canResolve` + * on review threads, from each comment's `capabilities`. + * - `toStack(stack, { repository, host }): ProviderChangeRequestStack` + * - `toDiff(diff): ProviderDiffSlice` + * - `toChecks(checks): ReadonlyArray` + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import * as GitCafePullRequestJson from "./gitCafePullRequestJson.ts"; + +const decodePull = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePull); +const decodePullDetail = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePullDetail); +const decodeComments = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeComments); +const decodeReviews = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReviews); +const decodeCommits = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeCommits); +const decodeReactions = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReactions); +const decodeStackEnvelope = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeStackEnvelope); +const decodeDiff = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeDiff); +const decodeChecks = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeChecks); + +const timestamp = "2026-09-12T12:00:00Z"; +const actor = { + kind: "local", + actorId: "act_one", + handle: "alice", + displayName: null, + avatarUrl: "/avatars/one", +}; +const pull = { + id: "pr_one", + number: 7, + title: "Change", + state: "open", + draft: true, + sourceBranch: "feature", + targetBranch: "main", + headOid: "abcdef", + author: actor, + createdAt: timestamp, + updatedAt: timestamp, + version: 1, +}; +const detail = { + ...pull, + draft: false, + description: "Body", + closedAt: null, + mergedAt: null, + sourceRepo: null, + capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true }, +}; +const lineComment = { + author: actor, + body: "Review", + path: "file.ts", + line: 3, + side: "right", + createdAt: timestamp, + commitOid: "abcdef", + resolvedAt: null, +}; +const capabilities = (overrides: Record = {}) => ({ + edit: false, + hide: false, + unhide: false, + delete: false, + resolve: false, + unresolve: false, + ...overrides, +}); +const noReviews = decodeReviews({ items: [], next: null }); +const noCommits = decodeCommits({ items: [], headOid: "abcdef", truncated: false, next: null }); +const activity = ( + comments: ReadonlyArray>, + extra: { readonly reactions?: unknown; readonly host?: string } = {}, +) => + GitCafePullRequestJson.toActivity({ + comments: decodeComments({ items: comments, next: null }), + reviews: noReviews, + commits: noCommits, + headOid: "abcdef", + host: extra.host ?? "git.cafe", + ...(extra.reactions === undefined ? {} : { reactions: decodeReactions(extra.reactions) }), + }); + +describe("toChangeRequest", () => { + it("reads draft state, the repository host's URLs and absolute avatars", () => { + const row = GitCafePullRequestJson.toChangeRequest(decodePull(pull), { + repository: "owner/repo", + host: "staging.git.cafe", + }); + assert.strictEqual(row.state, "open"); + assert.isTrue(row.isDraft); + assert.strictEqual(row.url, "https://staging.git.cafe/owner/repo/pulls/7"); + assert.deepStrictEqual(row.author, { + login: "alice", + name: null, + avatarUrl: "https://staging.git.cafe/avatars/one", + }); + }); + + it("names linked GitHub reviewers by both logins and drops unavailable ones", () => { + const row = GitCafePullRequestJson.toChangeRequest( + decodePull({ + ...pull, + author: { kind: "github", actorId: "act_remote", login: "remote-user", avatarUrl: null }, + reviewers: [ + { actor }, + { + actor: { + kind: "github", + actorId: "act_github", + login: "upstream", + avatarUrl: null, + linkedProfile: { handle: "linked-local" }, + }, + }, + { actor: { kind: "unavailable", actorId: "act_deleted" } }, + ], + }), + { repository: "owner/repo", host: "git.cafe" }, + ); + assert.strictEqual(row.author?.login, "remote-user"); + assert.deepStrictEqual(row.reviewRequestLogins, ["alice", "upstream", "linked-local"]); + }); + + it("requires capabilities on a detail response", () => { + assert.throws(() => decodePullDetail(pull)); + }); +}); + +describe("toViewerPermissions", () => { + const permissions = (overrides: Record) => + GitCafePullRequestJson.toViewerPermissions(decodePullDetail({ ...detail, ...overrides })); + + it("lets an editor rewrite the title and description, and nobody else", () => { + assert.isTrue(permissions({}).editChangeRequest); + assert.isFalse( + permissions({ + capabilities: { comment: true, review: true, merge: true, edit: false, moderate: true }, + }).editChangeRequest, + ); + }); + + it("offers lifecycle actions by state and never a standalone branch update", () => { + assert.deepStrictEqual(permissions({}).actions, ["draft", "close", "merge"]); + assert.deepStrictEqual(permissions({ state: "closed" }).actions, ["reopen"]); + assert.deepStrictEqual(permissions({ state: "merged" }).actions, []); + assert.isTrue(permissions({}).stackRebase); + }); + + it("keeps a locked conversation's composer for moderators only", () => { + const locked = (moderate: boolean) => + permissions({ + lockedAt: timestamp, + capabilities: { comment: true, review: false, merge: false, edit: false, moderate }, + }); + assert.isFalse(locked(false).comment); + assert.isFalse(locked(false).resolve); + assert.isTrue(locked(true).comment); + assert.deepStrictEqual(locked(true).verdicts, []); + }); + + it("offers no composer to a viewer GitCafe won't let comment", () => { + const silent = permissions({ + capabilities: { comment: false, review: true, merge: true, edit: true, moderate: true }, + }); + assert.isFalse(silent.comment); + assert.isFalse(silent.resolve); + }); +}); + +describe("toActivity", () => { + it("groups line comments by their persisted thread id and marks a paged list truncated", () => { + const result = GitCafePullRequestJson.toActivity({ + comments: decodeComments({ + items: [ + { ...lineComment, id: "reply", threadId: "thread-one" }, + { ...lineComment, id: "other", threadId: "thread-two" }, + ], + next: "next", + }), + reviews: noReviews, + commits: noCommits, + headOid: "abcdef", + host: "git.cafe", + }); + assert.deepStrictEqual(result.reviewThreads.map((thread) => thread.id).toSorted(), [ + "thread-one", + "thread-two", + ]); + assert.isTrue(result.commentsTruncated); + }); + + it.each([timestamp, null])("takes the thread's resolution from its root: %s", (resolvedAt) => { + const result = activity([ + { ...lineComment, id: "root", threadId: "root", resolvedAt }, + { ...lineComment, id: "reply", threadId: "root", resolvedAt: null }, + ]); + assert.strictEqual(result.reviewThreads.length, 1); + assert.strictEqual(result.reviewThreads[0]?.isResolved, resolvedAt !== null); + assert.strictEqual(result.reviewThreads[0]?.comments.length, 2); + }); + + it("carries per-remark edit permission onto both copies of a remark", () => { + const result = activity([ + { + ...lineComment, + id: "root", + threadId: "root", + capabilities: capabilities({ edit: false, resolve: true }), + }, + { + ...lineComment, + id: "reply", + threadId: "root", + capabilities: capabilities({ edit: true }), + }, + { ...lineComment, id: "silent", threadId: "silent", path: null, line: null, side: null }, + ]); + const canEdit = (id: string) => result.comments.find((comment) => comment.id === id)?.canEdit; + assert.strictEqual(canEdit("root"), false); + assert.strictEqual(canEdit("reply"), true); + // A host that says nothing about a remark leaves the page to guess. + assert.isUndefined(canEdit("silent")); + assert.deepStrictEqual( + result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.canEdit]), + [ + ["root", false], + ["reply", true], + ], + ); + }); + + it.each([ + [null, { resolve: true, unresolve: false }, true], + [null, { resolve: false, unresolve: true }, false], + [timestamp, { resolve: true, unresolve: false }, false], + [timestamp, { resolve: false, unresolve: true }, true], + ] as const)( + "reads canResolve for a thread resolved at %s with %o as %s", + (resolvedAt, allowed, canResolve) => { + const result = activity([ + { + ...lineComment, + id: "root", + threadId: "root", + resolvedAt, + capabilities: capabilities(allowed), + }, + ]); + assert.strictEqual(result.reviewThreads[0]?.canResolve, canResolve); + }, + ); + + it("leaves canResolve absent when the root says nothing", () => { + const result = activity([{ ...lineComment, id: "root", threadId: "root" }]); + assert.isUndefined(result.reviewThreads[0]?.canResolve); + }); + + it("maps review verdicts, dismissals and commits", () => { + const result = GitCafePullRequestJson.toActivity({ + comments: decodeComments({ items: [], next: null }), + reviews: decodeReviews({ + items: [ + { + id: "r1", + author: actor, + body: "LGTM", + verdict: "approve", + dismissedAt: null, + createdAt: timestamp, + }, + { + id: "r2", + author: actor, + body: null, + verdict: "request_changes", + dismissedAt: timestamp, + createdAt: "2026-09-12T13:00:00Z", + }, + ], + next: null, + }), + commits: decodeCommits({ + items: [{ oid: "abcdef", summary: "Change", time: 1_789_000_000 }], + headOid: "abcdef", + truncated: false, + next: null, + }), + host: "git.cafe", + }); + assert.deepStrictEqual( + result.comments.map((comment) => [comment.id, comment.kind, comment.reviewState]), + [ + ["r1", "review", "approved"], + ["r2", "review", "dismissed"], + ], + ); + assert.deepStrictEqual( + result.commits.map((commit) => commit.oid), + ["abcdef"], + ); + }); + + it("maps supported reactions to the pull request and to matching comments in both views", () => { + const result = activity( + [ + { ...lineComment, id: "comment-a", threadId: "comment-a" }, + { ...lineComment, id: "comment-b", threadId: "comment-a" }, + ], + { + reactions: { + next: null, + items: [ + { + subject: { kind: "pull_request_comment", id: "comment-b" }, + emoji: { kind: "unicode", value: "👍" }, + count: 3, + viewerReactionId: "reaction-viewer", + reactors: [actor, { kind: "unavailable", actorId: "deleted" }], + }, + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "unicode", value: "🚀" }, + count: 1, + viewerReactionId: null, + reactors: [actor], + }, + // Neither a custom emoji nor one outside the shared set is shown under another name. + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "custom", id: "party-parrot" }, + count: 2, + viewerReactionId: null, + reactors: [actor], + }, + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "unicode", value: "🔥" }, + count: 1, + viewerReactionId: null, + reactors: [actor], + }, + ], + }, + }, + ); + const thumbsUp = { + content: "thumbs-up", + count: 3, + actors: ["alice"], + viewerHasReacted: true, + } as const; + assert.deepStrictEqual(result.reactions, [ + { content: "rocket", count: 1, actors: ["alice"], viewerHasReacted: false }, + ]); + assert.deepStrictEqual( + result.comments.map((comment) => [comment.id, comment.reactions]), + [ + ["comment-a", []], + ["comment-b", [thumbsUp]], + ], + ); + assert.deepStrictEqual( + result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.reactions]), + [ + ["comment-a", []], + ["comment-b", [thumbsUp]], + ], + ); + }); +}); + +describe("toStack", () => { + it("orders members by position and reports heads only where GitCafe gives them", () => { + const { stack } = decodeStackEnvelope({ + stack: { + id: "stack-one", + number: 3, + revision: 1, + landingBase: "main", + members: [ + { + pullRequestNumber: 7, + title: "Top", + state: "open", + draft: true, + sourceBranch: "feature", + position: 2, + }, + { + pullRequestNumber: 6, + title: "Base", + state: "merged", + draft: false, + sourceBranch: "base", + headOid: "0123456789abcdef0123456789abcdef01234567", + position: 1, + }, + ], + }, + }); + assert.isNotNull(stack); + const result = GitCafePullRequestJson.toStack(stack!, { + repository: "owner/repo", + host: "staging.git.cafe", + }); + assert.strictEqual(result.url, "https://staging.git.cafe/owner/repo/stacks/3"); + assert.strictEqual(result.base, "main"); + assert.deepStrictEqual(result.layers, [ + { + number: 6, + title: "Base", + headBranch: "base", + state: "merged", + isDraft: false, + headSha: "0123456789abcdef0123456789abcdef01234567", + }, + { number: 7, title: "Top", headBranch: "feature", state: "open", isDraft: true }, + ]); + }); + + it("reads an unstacked pull request as no stack", () => { + assert.isNull(decodeStackEnvelope({ stack: null }).stack); + }); +}); + +describe("toDiff", () => { + it("converts numeric hunk coordinates into a unified patch", () => { + const diff = decodeDiff({ + items: [ + { + path: "file.ts", + oldPath: null, + status: "modified", + additions: 1, + deletions: 1, + binary: false, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "old\n" }, + { origin: "+", content: "new\n" }, + ], + }, + ], + }, + ], + truncated: false, + }); + assert.include(GitCafePullRequestJson.toDiff(diff).patch, "@@ -1,1 +1,1 @@\n-old\n+new\n"); + }); + + it("marks an omitted patch incomplete even when GitCafe's own flag is false", () => { + const diff = decodeDiff({ + items: [{ path: "large.ts", oldPath: null, status: "modified", tooLarge: true, hunks: [] }], + truncated: false, + }); + const result = GitCafePullRequestJson.toDiff(diff); + assert.isTrue(result.truncated); + assert.deepStrictEqual(result.omittedFileStats, [ + { path: "large.ts", additions: 0, deletions: 0 }, + ]); + }); + + it("quotes renamed and binary paths", () => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "new image.png", + oldPath: "old image.png", + status: "renamed", + binary: true, + hunks: [], + }, + ], + truncated: false, + }), + ); + // Git quotes a name for the bytes a header can't carry, not for a space. + assert.include(patch, "rename from old image.png"); + assert.include(patch, "Binary files a/old image.png and b/new image.png differ"); + }); + + it("C-escapes a name whose newline or tab would break the patch headers", () => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [{ path: "two\nlines\tname.ts", status: "added", hunks: [] }], + truncated: false, + }), + ); + assert.include(patch, 'diff --git "a/two\\nlines\\tname.ts" "b/two\\nlines\\tname.ts"'); + assert.notInclude(patch, "two\nlines"); + }); +}); + +describe("toChecks", () => { + it("maps GitCafe's status and conclusion onto the shared check states", () => { + const check = (status: string, conclusion: string | null) => ({ + name: `${status}-${conclusion}`, + status, + conclusion, + summary: null, + detailsUrl: null, + }); + const checks = GitCafePullRequestJson.toChecks( + decodeChecks({ + items: [ + check("queued", null), + check("in_progress", null), + check("completed", "success"), + check("completed", "action_required"), + check("completed", "timed_out"), + ], + next: null, + }), + ); + assert.deepStrictEqual( + checks.map((item) => item.status), + ["pending", "pending", "success", "action-required", "failure"], + ); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts new file mode 100644 index 000000000000..453083a7a6b4 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -0,0 +1,578 @@ +/** + * GitCafe's REST bodies, and their conversion into the neutral `Provider*` shapes. Every + * conversion takes the repository's host, so a staging repository's links stay on staging. + * + * @module source-control-gitcafe/server/gitCafePullRequestJson + */ +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + type PullRequestActor, + type PullRequestCheck, + type PullRequestComment, + type PullRequestReaction, + type PullRequestReactionContent, + type PullRequestReviewThread, + type PullRequestViewerPermissions, +} from "@t3tools/contracts"; +import type { + ProviderChangeRequest, + ProviderChangeRequestActivity, + ProviderChangeRequestStack, + ProviderDiffSlice, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +const Actor = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("local"), + actorId: Schema.String, + handle: TrimmedNonEmptyString, + displayName: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("github"), + actorId: Schema.String, + login: TrimmedNonEmptyString, + avatarUrl: Schema.NullOr(Schema.String), + linkedProfile: Schema.optional(Schema.NullOr(Schema.Struct({ handle: TrimmedNonEmptyString }))), + }), + Schema.Struct({ kind: Schema.Literal("unavailable"), actorId: Schema.String }), +]); +type Actor = typeof Actor.Type; + +const PullState = Schema.Literals(["open", "closed", "merged"]); + +export const GitCafePull = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + state: PullState, + draft: Schema.Boolean, + sourceBranch: Schema.String, + targetBranch: Schema.String, + headOid: Schema.NullOr(Schema.String), + author: Actor, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + version: NonNegativeInt, + reviewers: Schema.optional(Schema.Array(Schema.Struct({ actor: Actor }))), + labels: Schema.optional( + Schema.Array( + Schema.Struct({ name: TrimmedNonEmptyString, color: Schema.NullOr(Schema.String) }), + ), + ), +}); +export type GitCafePull = typeof GitCafePull.Type; + +export const GitCafePulls = Schema.Struct({ + items: Schema.Array(GitCafePull), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafePullDetail = Schema.Struct({ + ...GitCafePull.fields, + description: Schema.NullOr(Schema.String), + closedAt: Schema.NullOr(IsoDateTime), + mergedAt: Schema.NullOr(IsoDateTime), + sourceRepo: Schema.NullOr(Schema.Struct({ owner: Schema.String, name: Schema.String })), + /** The base GitCafe last compared the pull request against, which a provider merge fences on. */ + observedBaseOid: Schema.optional(Schema.NullOr(Schema.String)), + mergeRoute: Schema.optional(Schema.Literals(["native", "provider", "unsupported"])), + lockedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + capabilities: Schema.Struct({ + comment: Schema.Boolean, + review: Schema.Boolean, + merge: Schema.Boolean, + edit: Schema.Boolean, + moderate: Schema.Boolean, + }), +}); +export type GitCafePullDetail = typeof GitCafePullDetail.Type; + +const CommentCapabilities = Schema.Struct({ + edit: Schema.Boolean, + hide: Schema.Boolean, + unhide: Schema.Boolean, + delete: Schema.Boolean, + resolve: Schema.Boolean, + unresolve: Schema.Boolean, +}); + +const GitCafeComment = Schema.Struct({ + id: TrimmedNonEmptyString, + threadId: TrimmedNonEmptyString, + /** What a resolution or rewrite fences on; absent from older answers. */ + version: Schema.optional(NonNegativeInt), + author: Actor, + body: Schema.NullOr(Schema.String), + createdAt: IsoDateTime, + path: Schema.NullOr(Schema.String), + // The API allows any integer here; only a positive line anchors a review thread. + line: Schema.NullOr(Schema.Int), + side: Schema.NullOr(Schema.Literals(["left", "right"])), + commitOid: Schema.NullOr(Schema.String), + resolvedAt: Schema.NullOr(IsoDateTime), + capabilities: Schema.optional(CommentCapabilities), +}); +type GitCafeComment = typeof GitCafeComment.Type; + +export const GitCafeComments = Schema.Struct({ + items: Schema.Array(GitCafeComment), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafeReviews = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + id: TrimmedNonEmptyString, + author: Actor, + body: Schema.NullOr(Schema.String), + verdict: Schema.Literals(["approve", "request_changes", "comment"]), + dismissedAt: Schema.NullOr(IsoDateTime), + createdAt: IsoDateTime, + }), + ), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafeCommits = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ oid: TrimmedNonEmptyString, summary: Schema.String, time: Schema.Finite }), + ), + truncated: Schema.Boolean, + next: Schema.NullOr(Schema.String), + headOid: Schema.String, +}); + +export const GitCafeReactions = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + subject: Schema.Struct({ + kind: Schema.Literals(["issue", "issue_comment", "pull_request", "pull_request_comment"]), + id: TrimmedNonEmptyString, + }), + emoji: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("unicode"), value: TrimmedNonEmptyString }), + Schema.Struct({ kind: Schema.Literal("custom"), id: TrimmedNonEmptyString }), + ]), + count: PositiveInt, + viewerReactionId: Schema.NullOr(Schema.String), + reactors: Schema.Array(Actor), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +const GitCafeStack = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + /** What a land or restack fences on. */ + revision: PositiveInt, + landingBase: Schema.String, + members: Schema.Array( + Schema.Struct({ + pullRequestNumber: PositiveInt, + title: Schema.String, + state: PullState, + draft: Schema.Boolean, + sourceBranch: Schema.String, + headOid: Schema.optional(Schema.String), + position: PositiveInt, + }), + ), +}); +export type GitCafeStack = typeof GitCafeStack.Type; + +export const GitCafeStackEnvelope = Schema.Struct({ stack: Schema.NullOr(GitCafeStack) }); + +const GitCafeDiffFile = Schema.Struct({ + path: TrimmedNonEmptyString, + oldPath: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional( + Schema.Literals([ + "added", + "deleted", + "modified", + "typeChanged", + "conflicted", + "renamed", + "copied", + ]), + ), + additions: Schema.optional(NonNegativeInt), + deletions: Schema.optional(NonNegativeInt), + binary: Schema.optional(Schema.Boolean), + tooLarge: Schema.optional(Schema.Boolean), + hunksOmitted: Schema.optional(Schema.Boolean), + isSubmodule: Schema.optional(Schema.Boolean), + oldOid: Schema.optional(Schema.String), + newOid: Schema.optional(Schema.String), + // Structural pages (`/changes`, `/compare`) list files without hunks; the batched reads fill them. + hunks: Schema.optional( + Schema.Array( + Schema.Struct({ + oldStart: NonNegativeInt, + oldLines: NonNegativeInt, + newStart: NonNegativeInt, + newLines: NonNegativeInt, + lines: Schema.Array(Schema.Struct({ origin: Schema.String, content: Schema.String })), + }), + ), + ), +}); +export type GitCafeDiffFile = typeof GitCafeDiffFile.Type; +export const GitCafeDiffFiles = Schema.Array(GitCafeDiffFile); + +export const GitCafeDiff = Schema.Struct({ + items: GitCafeDiffFiles, + truncated: Schema.optional(Schema.Boolean), +}); + +export const GitCafeChecks = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + status: Schema.Literals(["queued", "in_progress", "completed"]), + conclusion: Schema.NullOr(Schema.String), + summary: Schema.NullOr(Schema.String), + detailsUrl: Schema.NullOr(Schema.String), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +interface RepositoryOnHost { + readonly repository: string; + readonly host: string; +} + +function toActor(actor: Actor, host: string): PullRequestActor | null { + if (actor.kind === "unavailable") return null; + return { + login: actor.kind === "local" ? actor.handle : actor.login, + name: actor.kind === "local" ? actor.displayName : null, + // GitCafe serves its own avatars by path, so a relative one is read against the host. + avatarUrl: actor.avatarUrl === null ? null : new URL(actor.avatarUrl, `https://${host}`).href, + }; +} + +/** The reviewers GitCafe has asked, as the detail's reviewer list shows them. */ +export function toReviewers( + pull: Pick, + host: string, +): ReadonlyArray { + return (pull.reviewers ?? []).flatMap(({ actor }) => { + const reviewer = toActor(actor, host); + return reviewer === null ? [] : [reviewer]; + }); +} + +/** A linked GitHub reviewer answers to both logins, so either one finds the review request. */ +function reviewerLogins(actor: Actor, host: string): ReadonlyArray { + const reviewer = toActor(actor, host); + if (reviewer === null) return []; + return actor.kind === "github" && actor.linkedProfile + ? [reviewer.login, actor.linkedProfile.handle] + : [reviewer.login]; +} + +export function toChangeRequest( + pull: GitCafePull, + target: RepositoryOnHost, +): ProviderChangeRequest { + return { + number: pull.number, + title: pull.title, + url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + author: toActor(pull.author, target.host), + headBranch: pull.sourceBranch, + baseBranch: pull.targetBranch, + state: pull.state, + isDraft: pull.draft, + mergeability: "unknown", + additions: 0, + deletions: 0, + createdAt: pull.createdAt, + updatedAt: pull.updatedAt, + reviewRequestLogins: (pull.reviewers ?? []).flatMap(({ actor }) => + reviewerLogins(actor, target.host), + ), + labels: pull.labels ?? [], + }; +} + +/** What GitCafe's own capabilities let this viewer do with the pull request. */ +export function toViewerPermissions(detail: GitCafePullDetail): PullRequestViewerPermissions { + const { comment, edit, review, merge, moderate } = detail.capabilities; + // A locked conversation keeps its composer for moderators only. + const open = comment && (detail.lockedAt == null || moderate); + return { + actions: [ + // A merged pull request is settled; a closed one can only be reopened. + ...(edit && detail.state === "open" + ? ([detail.draft ? "ready" : "draft", "close"] as const) + : edit && detail.state === "closed" + ? (["reopen"] as const) + : []), + ...(merge && detail.state === "open" && !detail.draft ? (["merge"] as const) : []), + ], + stackRebase: merge, + comment: open, + // Each thread's root narrows this further with its own `canResolve`. + resolve: open, + verdicts: review ? ["approve", "request-changes", "comment"] : [], + requestReviewers: false, + editChangeRequest: edit, + // GitCafe's only branch update moves a whole stack. + updateMethods: [], + }; +} + +const REACTIONS: Readonly> = { + "👍": "thumbs-up", + "👎": "thumbs-down", + "😄": "laugh", + "🎉": "hooray", + "😕": "confused", + "❤": "heart", + "🚀": "rocket", + "👀": "eyes", +}; + +/** The emoji GitCafe takes for one of the shared reactions. */ +export const REACTION_EMOJI: Readonly> = { + "thumbs-up": "👍", + "thumbs-down": "👎", + laugh: "😄", + hooray: "🎉", + confused: "😕", + heart: "❤️", + rocket: "🚀", + eyes: "👀", +}; + +/** The shared reaction an emoji stands for; custom and unlisted ones stand for none. */ +export function reactionContent(emoji: string): PullRequestReactionContent | undefined { + return REACTIONS[emoji.replaceAll("\uFE0F", "")]; +} + +function toReactions(data: typeof GitCafeReactions.Type, host: string) { + const reactions: Array = []; + const byComment = new Map>(); + for (const item of data.items) { + const content = item.emoji.kind === "unicode" ? reactionContent(item.emoji.value) : undefined; + if (content === undefined) continue; + const reaction: PullRequestReaction = { + content, + count: item.count, + actors: item.reactors.flatMap((actor) => { + const reactor = toActor(actor, host); + return reactor === null ? [] : [reactor.login]; + }), + viewerHasReacted: item.viewerReactionId !== null, + }; + if (item.subject.kind === "pull_request") reactions.push(reaction); + else if (item.subject.kind === "pull_request_comment") + byComment.set(item.subject.id, [...(byComment.get(item.subject.id) ?? []), reaction]); + } + return { reactions, byComment }; +} + +const REVIEW_STATES = { + approve: "approved", + request_changes: "changes_requested", + comment: "commented", +} as const; + +/** GitCafe persists a thread's identity apart from its line, so threads group by `threadId`. */ +export function toActivity(input: { + readonly comments: typeof GitCafeComments.Type; + readonly reviews: typeof GitCafeReviews.Type; + readonly commits: typeof GitCafeCommits.Type; + readonly reactions?: typeof GitCafeReactions.Type; + readonly headOid?: string; + readonly host: string; +}): ProviderChangeRequestActivity { + const { host } = input; + const reactions = input.reactions === undefined ? undefined : toReactions(input.reactions, host); + const remark = (comment: GitCafeComment) => ({ + id: comment.id, + author: toActor(comment.author, host), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: null, + ...(comment.capabilities === undefined ? {} : { canEdit: comment.capabilities.edit }), + ...(reactions === undefined ? {} : { reactions: reactions.byComment.get(comment.id) ?? [] }), + }); + // Stable, so remarks written in the same instant keep GitCafe's own order. + const ordered = input.comments.items.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + const groups = new Map>(); + for (const comment of ordered) { + if (!comment.path || comment.line === null || comment.line < 1 || comment.side === null) + continue; + groups.set(comment.threadId, [...(groups.get(comment.threadId) ?? []), comment]); + } + const reviewThreads = [...groups.values()].map((group): PullRequestReviewThread => { + const first = group[0]!; + const latest = group.at(-1)!; + const root = group.find((comment) => comment.id === comment.threadId); + return { + id: first.threadId, + path: first.path!, + line: first.line, + side: first.side!, + isResolved: root?.resolvedAt != null, + isOutdated: + input.headOid !== undefined && + latest.commitOid !== null && + latest.commitOid !== input.headOid, + comments: group.map(remark), + ...(root?.capabilities === undefined + ? {} + : { + canResolve: + root.resolvedAt === null ? root.capabilities.resolve : root.capabilities.unresolve, + }), + }; + }); + const comments: Array = [ + ...ordered.map((comment) => ({ + ...remark(comment), + kind: comment.path === null ? ("issue-comment" as const) : ("review-comment" as const), + path: comment.path, + reviewState: null, + })), + ...input.reviews.items.map((review) => ({ + id: review.id, + kind: "review" as const, + author: toActor(review.author, host), + body: review.body ?? "", + createdAt: review.createdAt, + url: null, + path: null, + reviewState: review.dismissedAt !== null ? "dismissed" : REVIEW_STATES[review.verdict], + })), + ]; + return { + comments: comments.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)), + commentCount: input.comments.items.length + input.reviews.items.length, + commentsTruncated: input.comments.next !== null || input.reviews.next !== null, + reviewThreads, + commits: input.commits.items.map((commit) => ({ + oid: commit.oid, + messageHeadline: commit.summary, + committedDate: DateTime.formatIso(DateTime.makeUnsafe(commit.time * 1_000)), + })), + ...(reactions === undefined ? {} : { reactions: reactions.reactions }), + }; +} + +export function toStack(stack: GitCafeStack, target: RepositoryOnHost): ProviderChangeRequestStack { + return { + id: stack.id, + number: stack.number, + url: `https://${target.host}/${target.repository}/stacks/${stack.number}`, + base: stack.landingBase, + layers: stack.members + .toSorted((a, b) => a.position - b.position) + .map((member) => ({ + number: member.pullRequestNumber, + title: member.title, + headBranch: member.sourceBranch, + state: member.state, + isDraft: member.draft, + ...(member.headOid === undefined ? {} : { headSha: member.headOid }), + })), + }; +} + +/** Converts GitCafe's structured hunks into a unified patch, keeping omitted files' counts. */ +export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { + const chunks: Array = []; + const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = []; + for (const file of diff.items) { + const oldPath = file.oldPath ?? file.path; + const a = quoteGitPatchPath(`a/${oldPath}`); + const b = quoteGitPatchPath(`b/${file.path}`); + const before = file.status === "added" ? "/dev/null" : a; + const after = file.status === "deleted" ? "/dev/null" : b; + const mode = file.isSubmodule ? "160000" : "100644"; + const lines = [`diff --git ${a} ${b}`]; + if (file.status === "added") lines.push(`new file mode ${mode}`); + if (file.status === "deleted") lines.push(`deleted file mode ${mode}`); + if (file.status === "renamed" || file.status === "copied") { + const action = file.status === "renamed" ? "rename" : "copy"; + lines.push( + `${action} from ${quoteGitPatchPath(oldPath)}`, + `${action} to ${quoteGitPatchPath(file.path)}`, + ); + } + if (file.binary) { + lines.push(`Binary files ${before} and ${after} differ`); + } else if (file.tooLarge || file.hunksOmitted) { + omittedFileStats.push({ + path: file.path, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + }); + } else if (file.isSubmodule) { + const hasOld = file.status !== "added" && file.oldOid !== undefined; + const hasNew = file.status !== "deleted" && file.newOid !== undefined; + lines.push(`--- ${before}`, `+++ ${after}`); + lines.push(`@@ -${hasOld ? "1" : "0,0"} +${hasNew ? "1" : "0,0"} @@`); + if (hasOld) lines.push(`-Subproject commit ${file.oldOid}`); + if (hasNew) lines.push(`+Subproject commit ${file.newOid}`); + } else { + const hunks = file.hunks ?? []; + if (hunks.length > 0) lines.push(`--- ${before}`, `+++ ${after}`); + for (const hunk of hunks) { + lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); + for (const line of hunk.lines) { + // GitCafe marks a missing final newline with libgit2's own origins. + if (["=", ">", "<"].includes(line.origin)) lines.push("\\ No newline at end of file"); + else lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`); + } + } + } + chunks.push(lines.join("\n")); + } + return { + patch: chunks.length === 0 ? "" : `${chunks.join("\n")}\n`, + truncated: diff.truncated === true || omittedFileStats.length > 0, + nextCursor: null, + ...(omittedFileStats.length === 0 ? {} : { omittedFileStats }), + }; +} + +function checkStatus(check: (typeof GitCafeChecks.Type.items)[number]): PullRequestCheck["status"] { + if (check.status !== "completed") return "pending"; + switch (check.conclusion) { + case "success": + return "success"; + case "neutral": + return "neutral"; + case "skipped": + return "skipped"; + case "cancelled": + return "cancelled"; + case "action_required": + return "action-required"; + default: + return "failure"; + } +} + +export function toChecks(checks: typeof GitCafeChecks.Type): ReadonlyArray { + return checks.items.map((check) => ({ + name: check.name, + status: checkStatus(check), + description: check.summary, + url: check.detailsUrl, + })); +} diff --git a/packages/source-control-gitcafe/tsconfig.json b/packages/source-control-gitcafe/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/source-control-gitcafe/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e44380dde31..249f45a453cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,6 +660,9 @@ importers: '@t3tools/source-control-forgejo': specifier: workspace:* version: link:../../packages/source-control-forgejo + '@t3tools/source-control-gitcafe': + specifier: workspace:* + version: link:../../packages/source-control-gitcafe '@t3tools/source-control-github': specifier: workspace:* version: link:../../packages/source-control-github @@ -1643,6 +1646,37 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-gitcafe: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/vitest': + specifier: 4.0.2 + version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../source-control-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-github: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 7bc6da481716..272026efd71e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -41,6 +41,7 @@ const workspaceFiles = [ "packages/source-control-bitbucket/package.json", "packages/source-control-core/package.json", "packages/source-control-forgejo/package.json", + "packages/source-control-gitcafe/package.json", "packages/source-control-github/package.json", "packages/source-control-gitlab/package.json", "packages/source-control-testing/package.json", From 500cb4266f2bb290aba2c2038295500ff7b25188 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:21:14 -0700 Subject: [PATCH 229/273] fix(web): add provider wizard no longer shifts sideways while it grows (#17292) --- apps/web/src/components/ui/wizard.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ui/wizard.tsx b/apps/web/src/components/ui/wizard.tsx index 695d4b047ff9..be2b10ab761c 100644 --- a/apps/web/src/components/ui/wizard.tsx +++ b/apps/web/src/components/ui/wizard.tsx @@ -4,6 +4,7 @@ import type { ComponentProps, ReactNode } from "react"; import { cn } from "../../lib/utils"; import { AnimatedHeight } from "../AnimatedHeight"; import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog"; +import { ScrollArea } from "./scroll-area"; /** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */ export function WizardPopup({ @@ -14,14 +15,10 @@ export function WizardPopup({ readonly size?: "default" | "wide"; }) { return ( - -

+ + +
{children}
+
); } From 42f71dfe0768ee768283d50140e7d360a635ee28 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:23:15 -0700 Subject: [PATCH 230/273] fix(web): PR search keeps the caret where you type (#17675) --- .../components/pullRequest/PullRequestListFilters.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index cbb0e01b9da3..1fb90f0b3052 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -104,6 +104,7 @@ export function PullRequestSearchInput({ busy?: boolean; onChange: (value: string) => void; }) { + const [draft, setDraft] = useState(null); return ( @@ -111,8 +112,13 @@ export function PullRequestSearchInput({ onChange(event.currentTarget.value)} + value={draft ?? value} + onFocus={() => setDraft(value)} + onBlur={() => setDraft(null)} + onChange={(event) => { + setDraft(event.currentTarget.value); + onChange(event.currentTarget.value); + }} placeholder="Search pull requests, or label:bug" aria-label="Search pull requests" /> From 6d5ea190a4a54b01dfe6500e032526839e3258d1 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:24:11 -0700 Subject: [PATCH 231/273] fix(server): thread PR badges catch up when another environment reads the PR (#17729) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../pullRequest/PullRequestService.test.ts | 42 +++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 41 ++++++++++++++++++ apps/server/src/ws.ts | 1 + .../src/state/pullRequestRouting.ts | 22 ++++++++++ .../src/state/pullRequests.test.ts | 11 +++++ packages/contracts/src/pullRequest.ts | 11 +++++ packages/contracts/src/rpc.ts | 9 ++++ 9 files changed, 139 insertions(+) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 511fd77cd147..11756026f6d1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -135,6 +135,7 @@ export const RPC_REQUIRED_SCOPES = { // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsReportState]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSubscribeRefreshes]: AuthOrchestrationReadScope, // The candidate list is a read like the detail beside it; asking somebody for a review is a // write like every other one. diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2ad903ff792a..26a4b007b8b6 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -118,6 +118,7 @@ const RPC_AGGREGATES = { [WS_METHODS.pullRequestsSetReaction]: "pull-requests", [WS_METHODS.pullRequestsSetFilesViewed]: "pull-requests", [WS_METHODS.pullRequestsInvalidate]: "pull-requests", + [WS_METHODS.pullRequestsReportState]: "pull-requests", [WS_METHODS.pullRequestsSubscribeRefreshes]: "pull-requests", [WS_METHODS.pullRequestsReviewerCandidates]: "pull-requests", [WS_METHODS.pullRequestsRequestReviewers]: "pull-requests", diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index c74faa92f06d..117fd3f69a32 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4972,6 +4972,48 @@ it.effect("announces state a detail read sees first or newly", () => }), ); +it.effect("announces a reported state without trusting it", () => + Effect.gen(function* () { + let detail = { state: "open" as "open" | "closed", updatedAt: "2026-07-02T00:00:00Z" }; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), + }), + ], + }); + const announced: Array = []; + yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) => + Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)), + ).pipe(Effect.forkChild({ startImmediately: true })); + const note = (state: "open" | "closed" | "merged") => + service.reportState({ reference, state }).pipe(Effect.andThen(Effect.yieldNow)); + const readDetail = Effect.gen(function* () { + yield* service.invalidate({ reference }); + yield* service.detail(reference); + yield* Effect.yieldNow; + }); + + yield* readDetail; + yield* note("closed"); + yield* note("closed"); + assert.strictEqual(announced.length, 2); + yield* TestClock.adjust("5 minutes"); + yield* note("closed"); + assert.strictEqual(announced.length, 3); + + yield* note("open"); + assert.strictEqual(announced.length, 4); + + yield* note("merged"); + detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" }; + yield* readDetail; + assert.strictEqual(announced.length, 6); + }), +); + it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () => Effect.gen(function* () { const gate = yield* Deferred.make(); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index b44d713c3158..51377f1b33b1 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -42,6 +42,7 @@ import { type PullRequestFilesViewedResult, type PullRequestDiffResult, type PullRequestInvalidateInput, + type PullRequestReportStateInput, type PullRequestListEntry, type PullRequestListFilters, type PullRequestListInput, @@ -160,6 +161,8 @@ const detailTimeToLive = (state: PullRequestState | undefined) => * page and a watched pull request's change reaches its watch. */ const CHECKS_CACHE_TTL = Duration.seconds(15); +/** How long a repeated reported state is skipped, so a wrong one cannot hide a real change for long. */ +const REPORTED_STATE_DEDUPE_MS = 5 * 60 * 1_000; const DIFF_CACHE_TTL = Duration.seconds(60); /** A commit is content-addressed, so its own diff cannot change under its key. */ const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); @@ -244,6 +247,13 @@ export class PullRequestService extends Context.Service< never, Scope.Scope >; + /** + * The state a read routed to another environment saw. A new one reaches + * `subscribeStateChanges`, so the host is asked again; the report itself is never trusted. + */ + readonly reportState: ( + input: PullRequestReportStateInput, + ) => Effect.Effect; readonly subscribeRefreshes: Stream.Stream; readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; @@ -3115,6 +3125,36 @@ export const make = Effect.gen(function* () { }) : Effect.void; }); + // The last state each routed read reported, kept apart from `detailStates`: an unverified report + // must never outrank a reading this environment made itself. + const reportedStates = new Map< + string, + { readonly state: PullRequestState; readonly atMs: number } + >(); + const reportState: PullRequestService["Service"]["reportState"] = ({ reference, state }) => + Effect.all([canonicalRef(reference), Clock.currentTimeMillis]).pipe( + Effect.flatMap(([ref, nowMs]) => + Effect.suspend(() => { + const scope = refScope(ref); + const reported = reportedStates.get(scope); + if (reported?.state === state && nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS) + return Effect.void; + reportedStates.delete(scope); + if (reportedStates.size >= REF_EPOCH_CAPACITY) { + const oldest = reportedStates.keys().next().value; + if (oldest !== undefined) reportedStates.delete(oldest); + } + reportedStates.set(scope, { state, atMs: nowMs }); + return ref.host === undefined + ? Effect.void + : PubSub.publish(stateChanges, { + host: ref.host, + repository: ref.repository, + number: ref.number, + }); + }), + ), + ); const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); // Record the summary from a host or cache read, not the stale value @@ -3432,6 +3472,7 @@ export const make = Effect.gen(function* () { subscribeStateChanges: PubSub.subscribe(stateChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), + reportState, subscribeRefreshes: SubscriptionRef.changes(pullRequestRefreshes).pipe( Stream.filter((revision) => revision > 0), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fce520ca7e3b..c1a01b963a50 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2518,6 +2518,7 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.setThreadResolution(input)), [WS_METHODS.pullRequestsSetReaction]: (input) => withPullRequestViewer(input, pullRequests.setReaction(input)), + [WS_METHODS.pullRequestsReportState]: (input) => pullRequests.reportState(input), [WS_METHODS.pullRequestsInvalidate]: (input) => pullRequests.invalidate(input, { notifyReaders: true }).pipe( // A reader asking for fresh host state also wants the thread badges it feeds to diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index f8781837c361..2233dd8a6722 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -2,6 +2,7 @@ import { WS_METHODS, PullRequestRef, PullRequestInvalidateInput, + PullRequestState, type EnvironmentId, type PullRequestRoutingResult, type PullRequestRoutingIdentityResult, @@ -57,6 +58,7 @@ const writes = new Set([ WS_METHODS.pullRequestsSetLabels, ]); const isRef = Schema.is(PullRequestRef); +const hasState = Schema.is(Schema.Struct({ state: PullRequestState })); const isInvalidation = Schema.is(PullRequestInvalidateInput); const readTimeout = (environmentId: EnvironmentId) => Effect.timeoutOrElse({ @@ -208,6 +210,21 @@ const invalidateTarget = Effect.fn("PullRequestRouting.invalidateTarget")(functi ); }); +/** + * The origin owns the threads linked to this pull request, but a routed read never reaches it. + * Tell it which state the read saw so it checks again. Best effort, and never holds up the read. + */ +const reportState = ( + { allowStale: _allowStale, expectedAccountId: _expectedAccountId, ...reference }: PullRequestRef, + state: PullRequestState, +) => + request(WS_METHODS.pullRequestsReportState, { reference, state }).pipe( + Effect.timeoutOption("5 seconds"), + Effect.ignore, + Effect.forkDetach, + Effect.asVoid, + ); + /** Credentials stay on their environments. Only a verified host and account cross the wire. */ export function createPullRequestRouter() { const routedRequest = Effect.fn("PullRequestRouting.request")(function* < @@ -356,6 +373,11 @@ export function createPullRequestRouter() { ? guardedSource : registry.run(id, request(tag, routedInput)) ).pipe( + Effect.tap((result) => + id !== origin.target.environmentId && hasState(result) + ? reportState(ref, result.state) + : Effect.void, + ), Effect.tap(() => Effect.sync(() => { const entry = used.get(refKey) ?? { diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 8762e9edb399..75b76d8c3f4e 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -995,6 +995,7 @@ it.effect.each( it.live("keeps source workspace metadata when an alternate answers a detail read", () => Effect.scoped( Effect.gen(function* () { + const noted = yield* Deferred.make(); const clientFor = (local: boolean) => ({ [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => @@ -1013,8 +1014,12 @@ it.live("keeps source workspace metadata when an alternate answers a detail read projectTitle: "local project", workspaceRoot: "/Users/local/repo", title: "github title", + state: "closed", + updatedAt: "2026-10-10T01:52:36Z", }) : Effect.never, + [WS_METHODS.pullRequestsReportState]: (input: unknown) => + Deferred.succeed(noted, input).pipe(Effect.asVoid), }) as unknown as WsRpcProtocolClient; const { environmentRegistry, supervisor } = yield* makeTestRuntime( clientFor(false), @@ -1035,6 +1040,12 @@ it.live("keeps source workspace metadata when an alternate answers a detail read projectTitle: "source project", workspaceRoot: "/srv/source/repo", title: "github title", + state: "closed", + updatedAt: "2026-10-10T01:52:36Z", + }); + expect(yield* Deferred.await(noted)).toEqual({ + reference: { projectId: "project-1", repository: "acme/web", number: 7 }, + state: "closed", }); }), ), diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 1efbc1808d14..82e5dc1cd15f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -849,6 +849,17 @@ export const PullRequestInvalidateInput = Schema.Struct({ }); export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type; +/** + * The state a read routed to another environment saw, reported to the environment the read was + * for so its thread links can catch up. A hint only: that environment confirms with the host + * before writing anything. + */ +export const PullRequestReportStateInput = Schema.Struct({ + reference: PullRequestRef, + state: PullRequestState, +}); +export type PullRequestReportStateInput = typeof PullRequestReportStateInput.Type; + export const PullRequestDetail = Schema.Struct({ provider: SourceControlProviderKind, capabilities: PullRequestCapabilities, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 36afee2f9efa..289b7041be16 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -171,6 +171,7 @@ import { PullRequestDiffFileContentsResult, PullRequestFilesViewedResult, PullRequestInvalidateInput, + PullRequestReportStateInput, PullRequestListInput, PullRequestListResult, PullRequestListStatsInput, @@ -543,6 +544,7 @@ export const WS_METHODS = { pullRequestsSetThreadResolution: "pullRequests.setThreadResolution", pullRequestsSetReaction: "pullRequests.setReaction", pullRequestsInvalidate: "pullRequests.invalidate", + pullRequestsReportState: "pullRequests.reportState", pullRequestsSubscribeRefreshes: "pullRequests.subscribeRefreshes", pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates", pullRequestsRequestReviewers: "pullRequests.requestReviewers", @@ -1087,6 +1089,12 @@ const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, error: PullRequestRpcError, }); +const WsPullRequestsReportStateRpc = Rpc.make(WS_METHODS.pullRequestsReportState, { + payload: PullRequestReportStateInput, + success: Schema.Void, + error: PullRequestRpcError, +}); + const WsPullRequestsSubscribeRefreshesRpc = Rpc.make(WS_METHODS.pullRequestsSubscribeRefreshes, { payload: Schema.Struct({}), success: NonNegativeInt, @@ -1916,6 +1924,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsSetThreadResolutionRpc, WsPullRequestsSetReactionRpc, WsPullRequestsInvalidateRpc, + WsPullRequestsReportStateRpc, WsPullRequestsSubscribeRefreshesRpc, WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, From bd2346eda2e2c380d1844869c7fd16c279d2190f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 00:36:04 -0700 Subject: [PATCH 232/273] fix(server): refuse editor paths with line breaks or quotes when the editor is a Windows command shim (#17749) Co-authored-by: Claude Opus 5.5 --- .../src/process/externalLauncher.test.ts | 89 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 13 +++ packages/contracts/src/editor.ts | 13 +++ 3 files changed, 115 insertions(+) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 6df4ea16db88..d16391a3ae70 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -7,7 +7,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -1381,6 +1383,93 @@ it.effect("keeps scanning after the caller is interrupted and shares that scan", ); }); +const launchWindowsShimEditor = (cwd: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + let spawned: ChildProcess.StandardCommand | undefined; + const exit = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.launchEditor({ editor: "vscode", cwd }).pipe(Effect.exit); + }).pipe( + Effect.provide( + layerTest({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + return { exit, spawned }; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +it.effect.each([ + ["CRLF", "C:\\workspace\\file.ts\r\ncalc.exe"], + ["LF", "C:\\workspace\\file.ts\ncalc.exe"], + ["double quote", 'C:\\workspace\\file.ts" & calc.exe & "'], +] as const)("does not start a Windows command shim for a path with a $0", ([, cwd]) => + Effect.gen(function* () { + const { exit, spawned } = yield* launchWindowsShimEditor(cwd); + assert.equal(spawned, undefined); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.instanceOf( + Cause.squash(exit.cause), + ExternalLauncher.ExternalLauncherUnsupportedTargetError, + ); + } + }), +); + +it.effect("starts a Windows command shim for unicode paths with cmd metacharacters", () => + Effect.gen(function* () { + const { exit, spawned } = yield* launchWindowsShimEditor( + "C:\\Users\\jö\\R&D (100%)\\naïve ^file!.ts:3:7", + ); + assert.isTrue(Exit.isSuccess(exit)); + assert.ok(spawned); + assert.equal(spawned.options.shell, true); + assert.deepEqual(spawned.args, [ + '^"--goto^"', + '^"C:\\Users\\jö\\R^&D^ ^(100^%^)\\naïve^ ^^file^!.ts:3:7^"', + ]); + }), +); + +it.effect.skipIf(windowsHost)("passes line breaks through to editors started without a shell", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const zed = path.join(binDir, "zed"); + yield* fileSystem.writeFileString(zed, "#!/bin/sh\n"); + yield* fileSystem.chmod(zed, 0o755); + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace/odd\nname.ts" }); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { PATH: binDir, HOME: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + assert.deepEqual(spawned.args, ["/workspace/odd\nname.ts"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 74e95b9ddcc4..8ab130b01e38 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -14,6 +14,7 @@ import { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, + ExternalLauncherUnsupportedTargetError, type EditorId, type FileManagerRevealKind, type LaunchEditorInput, @@ -53,6 +54,7 @@ export { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, + ExternalLauncherUnsupportedTargetError, } from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { @@ -74,6 +76,14 @@ interface TargetPathAndPosition { readonly column: Option.Option; } +/** + * Windows command shims (`code.cmd`) forward arguments through `%*`, which + * cmd.exe parses a second time after the first escaping layer is gone: a + * line break ends the command there, and a double quote closes the quoting + * that keeps `&` or `|` literal. Neither can occur in a Windows path. + */ +// oxlint-disable-next-line no-control-regex +const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/; const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", @@ -726,6 +736,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu } const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env }); + if (spawnCommand.shell && launch.args.some((arg) => WINDOWS_SHIM_UNSAFE_ARG_PATTERN.test(arg))) { + return yield* new ExternalLauncherUnsupportedTargetError({ editor: launch.editor }); + } yield* launchAndUnref( { command: spawnCommand.command, diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 75dca95786bf..7fa324a425ad 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -283,6 +283,18 @@ export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError()( + "ExternalLauncherUnsupportedTargetError", + { + editor: EditorId, + }, +) { + override get message(): string { + return `${this.editor} cannot open a path containing line breaks or double quotes`; + } +} + const ExternalLauncherSpawnFields = { command: Schema.String, args: Schema.Array(Schema.String), @@ -318,6 +330,7 @@ export const ExternalLauncherError = Schema.Union([ ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, ExternalLauncherCommandNotFoundError, + ExternalLauncherUnsupportedTargetError, ExternalLauncherBrowserSpawnError, ExternalLauncherEditorSpawnError, ]); From c77a7b7eebd6ea8a3a63bbcaaa7c1707fe65c86e Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 10 Oct 2026 03:34:14 -0500 Subject: [PATCH 233/273] fix(web): align compact button touch targets (#17748) --- .../BranchToolbarBranchSelector.tsx | 6 ++- .../src/components/ComposerContextLabel.tsx | 4 +- apps/web/src/components/GitActionsControl.tsx | 41 +++++++++++-------- .../src/components/ProjectScriptsControl.tsx | 9 ++-- .../chat/ComposerPrimaryActions.tsx | 2 +- apps/web/src/components/chat/OpenInPicker.tsx | 3 +- .../components/chat/ThreadDetailsPrRow.tsx | 5 ++- .../chat/threadDetailsPanelStyles.ts | 5 +++ .../web/src/components/ui/middle-truncate.tsx | 10 ++++- 9 files changed, 58 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 1d4185b9092f..7af46f2d47a2 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -779,7 +779,11 @@ export function BranchToolbarBranchSelector({ )} /> - + {displayMode !== "panel" ? ( diff --git a/apps/web/src/components/ComposerContextLabel.tsx b/apps/web/src/components/ComposerContextLabel.tsx index 1abecfdf8ca8..8efcba43a75c 100644 --- a/apps/web/src/components/ComposerContextLabel.tsx +++ b/apps/web/src/components/ComposerContextLabel.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { cn } from "../lib/utils"; +import { THREAD_DETAILS_PANEL_LABEL_CLASS } from "./chat/threadDetailsPanelStyles"; /** Keeps text measurable while the composer's outer label box collapses. */ export function ComposerContextLabel({ @@ -16,7 +17,7 @@ export function ComposerContextLabel({ className={cn( "min-w-0", displayMode === "panel" - ? "flex-1 truncate text-left" + ? "flex-1 overflow-x-clip overflow-y-visible text-left" : "max-w-[240px] group-data-[compact]/composer-context:max-w-0", )} > @@ -24,6 +25,7 @@ export function ComposerContextLabel({ data-composer-label-motion className={cn( "block w-full min-w-0 truncate", + displayMode === "panel" && THREAD_DETAILS_PANEL_LABEL_CLASS, displayMode === "toolbar" && "max-w-[240px] transition-opacity duration-180 ease-drawer group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none", )} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2b684cd0cd4e..2063767f315c 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -127,6 +127,7 @@ import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./chat/threadDetailsPanelStyles"; @@ -441,23 +442,25 @@ function GitActionProgressButtonContent({ aria-live="polite" className={cn( "grid min-w-0 flex-1 items-center", - // Pin the title row to the button's minimum content height (min-height - // minus vertical padding and border) so revealing the output row - // extends the button downward without re-centering — the title must - // not shift. No row gap: the collapsed output row must contribute zero - // height so the single-line running button matches the static button - // exactly. The panel column gap matches the static row's icon-to-label - // distance (gap-2.5 plus the label's ml-0.5). In the panel the elapsed - // counter renders outside the button (in the menu-chevron slot), so - // there is no trailing column. + // Pin the title row to the button's minimum content height so output + // expands below it. Panel controls leave 24px after padding and border. + // No row gap: collapsed output must not add height. The panel's elapsed + // counter uses the menu-chevron slot, so it has no trailing column. isPanel - ? "grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.75rem] gap-x-3" + ? "grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.5rem] gap-x-3" : "grid-cols-[auto_minmax(0,1fr)_auto] grid-rows-[1.25rem] gap-x-2.5 sm:grid-rows-[1rem]", )} role="status" >