From 9ecfc07a8b3acadd1612665b6e24425f625480de Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 31 Aug 2026 22:46:21 -0400 Subject: [PATCH 001/136] fix(chat): keep agent activity visible between actions (#8984) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../chat/MessagesTimeline.logic.test.ts | 49 ++++++----- .../components/chat/MessagesTimeline.logic.ts | 68 +++++++++------ .../components/chat/MessagesTimeline.test.tsx | 13 +-- .../src/components/chat/MessagesTimeline.tsx | 86 +++++++++++-------- 4 files changed, 123 insertions(+), 93 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 9e18f8f004431..d9bfdae04d493 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -826,10 +826,11 @@ describe("deriveMessagesTimelineRows", () => { "assistant-final-entry", "user-followup-entry", "working-indicator-row", + "thinking-indicator-row", ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); - expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it("does not fold the active in-progress turn", () => { @@ -886,18 +887,18 @@ describe("deriveMessagesTimelineRows", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { - id: "completed-command-entry", + id: "running-command-entry", kind: "work", createdAt: "2026-01-01T00:00:05Z", entry: { - id: "completed-command", + id: "running-command", createdAt: "2026-01-01T00:00:05Z", turnId: "turn-1" as never, - label: "Ran rg", + label: "Running rg", command: "rg toolCall", requestKind: "command", tone: "tool" as const, - toolLifecycleStatus: "completed" as const, + toolLifecycleStatus: "inProgress" as const, }, }, { @@ -916,18 +917,18 @@ describe("deriveMessagesTimelineRows", () => { }, }, { - id: "running-command-entry", + id: "completed-command-entry", kind: "work", createdAt: "2026-01-01T00:00:07Z", entry: { - id: "running-command", + id: "completed-command", createdAt: "2026-01-01T00:00:07Z", turnId: "turn-1" as never, - label: "Running tests", + label: "Ran tests", command: "vp test run", requestKind: "command", tone: "tool" as const, - toolLifecycleStatus: "inProgress" as const, + toolLifecycleStatus: "completed" as const, }, }, ], @@ -944,13 +945,13 @@ describe("deriveMessagesTimelineRows", () => { }); expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); - expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); + expect(rows.some((row) => row.kind === "thinking")).toBe(false); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, groupedEntries: [ - { id: "completed-command" }, - { id: "completed-edit" }, { id: "running-command" }, + { id: "completed-edit" }, + { id: "completed-command" }, ], }); }); @@ -1195,7 +1196,7 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("keeps the latest completed tool call live while the turn is running", () => { + it("shows thinking after the latest tool call completes while the turn is running", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -1226,11 +1227,9 @@ describe("deriveMessagesTimelineRows", () => { revertTurnCountByUserMessageId: new Map(), }); - expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); - expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ - entry: { id: "latest-command" }, - groupedEntries: [{ id: "latest-command" }], - }); + expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1378,7 +1377,7 @@ describe("deriveMessagesTimelineRows", () => { expect(assistantRow?.showAssistantMeta).toBe(false); expect(assistantRow?.showAssistantCopyButton).toBe(false); - expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it.each([ @@ -1547,7 +1546,7 @@ describe("deriveMessagesTimelineRows", () => { }); describe("computeStableMessagesTimelineRows", () => { - it.each(["", " \n"])("replaces Thinking when assistant content grows from %j", (text) => { + it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => { const startedAt = "2026-01-01T00:00:00Z"; const turnId = TurnId.make("turn-1"); const input = { @@ -1588,11 +1587,11 @@ describe("computeStableMessagesTimelineRows", () => { initial, ); - const initialWorking = initial.byId.get("working-indicator-row"); - const updatedWorking = updated.byId.get("working-indicator-row"); - expect(initialWorking).toMatchObject({ showThinking: true }); - expect(updatedWorking).toMatchObject({ showThinking: false }); - expect(updatedWorking).not.toBe(initialWorking); + const initialThinking = initial.byId.get("thinking-indicator-row"); + const updatedThinking = updated.byId.get("thinking-indicator-row"); + expect(initialThinking).toMatchObject({ kind: "thinking" }); + expect(updatedThinking).toBe(initialThinking); + expect(updated.result.at(-1)).toBe(updatedThinking); }); it("returns the previous result when row order and content are unchanged", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c190643ee7f36..66a3b3b674408 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -193,6 +193,7 @@ export type MessagesTimelineRow = groupedEntries: WorkLogEntry[]; groupId: string; expanded: boolean; + active: boolean; } | { kind: "work-toggle"; @@ -235,7 +236,11 @@ export type MessagesTimelineRow = kind: "working"; id: string; createdAt: string | null; - showThinking: boolean; + } + | { + kind: "thinking"; + id: string; + createdAt: string | null; }; export interface StableMessagesTimelineRowsState { @@ -512,6 +517,14 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; } +function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { + return ( + entry.toolLifecycleStatus === "inProgress" || + entry.sourceActivityKind === "task.progress" || + (entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry)) + ); +} + /** * Settled turns keep only their terminal assistant message visible. * Everything before it folds behind a "Worked for ..." row anchored at the @@ -702,24 +715,6 @@ export function deriveMessagesTimelineRows(input: { unsettledTurnId !== null && entry.toolLifecycleStatus === "inProgress" && entry.turnId === unsettledTurnId; - const activeEntries = input.isWorking - ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) - : []; - const activeTurnHasVisibleContent = activeEntries.some((entry) => { - if (entry.kind === "message") { - return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; - } - if (entry.kind === "work") { - return ( - entry.entry.agentSpawn === undefined && - workLogEntryIsToolLike(entry.entry) && - entry.entry.toolLifecycleStatus === "inProgress" - ); - } - if (entry.kind === "proposed-plan") return true; - return false; - }); - const activeToolEntries: Array> = []; for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) { const entry = input.timelineEntries[index]!; @@ -733,40 +728,48 @@ export function deriveMessagesTimelineRows(input: { } activeToolEntries.unshift(entry); } - const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id)); const visibleActiveToolEntries = omitSupersededLifecycleMarkers( activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)), (entry) => entry.entry, ); const activeWorkAnchor = activeToolEntries[0]; - const latestActiveToolEntry = visibleActiveToolEntries.at(-1); - const activeWorkPlacementEntryId = latestActiveToolEntry?.id; + const latestVisibleToolEntry = visibleActiveToolEntries.at(-1); + const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => + workEntryIsActiveTurnActivity(entry.entry), + ); + const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; + const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && latestActiveToolEntry + activeWorkAnchor && displayedToolEntry ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { kind: "work-live" as const, id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, createdAt: activeWorkAnchor.createdAt, - entry: latestActiveToolEntry.entry, + entry: displayedToolEntry.entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + active: latestRunningToolEntry !== undefined, }; })() : null; + const activeWorkEntryIds = new Set( + activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id), + ); const appendWorkingRow = () => { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, - showThinking: activeWorkRow === null && !activeTurnHasVisibleContent, }); }; + let hasLiveWorkRow = false; const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); + hasLiveWorkRow ||= activeWorkRow.active; if (!activeWorkRow.expanded) return; for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { nextRows.push({ @@ -864,7 +867,9 @@ export function deriveMessagesTimelineRows(input: { groupedEntries: visibleGroupedEntries, groupId, expanded, + active: true, }); + hasLiveWorkRow = true; if (expanded) { for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { nextRows.push({ @@ -966,6 +971,13 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } + if (input.isWorking && !hasLiveWorkRow) { + nextRows.push({ + kind: "thinking", + id: "thinking-indicator-row", + createdAt: input.activeTurnStartedAt, + }); + } return nextRows; } @@ -996,9 +1008,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return ( - a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking - ); + case "thinking": + return a.createdAt === (b as typeof a).createdAt; case "turn-fold": { const bf = b as typeof a; @@ -1023,6 +1034,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.createdAt === bw.createdAt && a.groupId === bw.groupId && a.expanded === bw.expanded && + a.active === bw.active && Equal.equals(a.entry, bw.entry) && Equal.equals(a.groupedEntries, bw.groupedEntries) ); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 9a8ad8f6c0385..024dfe69d2782 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1308,7 +1308,7 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("keeps terminal command copy live while the parent turn is active", () => { + it("keeps declined command copy visible while thinking continues", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { runningTurnId={turnId} timelineEntries={[ { - id: "entry-failed", + id: "entry-declined", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { - id: "work-failed", + id: "work-declined", createdAt: MESSAGE_CREATED_AT, turnId, - toolCallId: "call-failed", + toolCallId: "call-declined", label: "Run lint", tone: "tool", itemType: "command_execution", command: "pnpm lint", - toolLifecycleStatus: "failed", + toolLifecycleStatus: "declined", }, }, ]} />, ); - expect(markup).toContain("Running pnpm"); + expect(markup).toContain("Declined pnpm"); + expect(markup).toContain("Thinking"); expect(markup).toContain("tool call failed"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 81ab67f6ffe35..d49c355e5b3b0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -981,7 +981,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time : "pb-0" : isExpandedToolGroupHeader ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" + : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" ? "pb-1.5" : (row.kind === "message" && row.message.role === "assistant" && @@ -1013,6 +1013,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} + {row.kind === "thinking" ? : null} ); }); @@ -1330,34 +1331,36 @@ function ProposedPlanTimelineRow({ function WorkingTimelineRow({ row }: { row: Extract }) { const { isPreparingWorktree } = use(TimelineRowActivityCtx); return ( -
-
-
- - {isPreparingWorktree ? ( - <> - Setting up worktree… - Setting up worktree… - - ) : row.createdAt ? ( - <> - Working for - - ) : ( - "Working..." - )} - -
+
+
+ + {isPreparingWorktree ? ( + <> + Setting up worktree… + Setting up worktree… + + ) : row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + )} +
- {row.showThinking ? ( - // Reserve the activity row during setup so the handoff keeps the same height. -
- {isPreparingWorktree ? null : } -
- ) : null} +
+ ); +} + +function ThinkingTimelineRow() { + const { isPreparingWorktree } = use(TimelineRowActivityCtx); + // Reserve the activity row during setup so the handoff keeps the same height. + return ( +
+ {isPreparingWorktree ? null : }
); } @@ -1514,7 +1517,7 @@ function LiveActivityContent({ function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot); + const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const failed = workEntryDisplayIndicatesToolFailure(row.entry); return ( @@ -1525,7 +1528,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > - + {row.active ? ( + + ) : ( +
+ +
+ )} ); } @@ -2205,14 +2219,18 @@ function workEntryRawCommand( function liveWorkEntryLabel( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, + active: boolean, ): string { const command = workEntry.command?.trim(); if (command) { - // This row describes the active parent turn, not the command lifecycle. - // Keep its live "Running" copy until the turn or contiguous tool run settles. const program = commandProgramName(command); - if (program) return `Running ${program}`; - return "Running command"; + const verb = active + ? "Running" + : workEntry.toolLifecycleStatus === "declined" + ? "Declined" + : "Ran"; + if (program) return `${verb} ${program}`; + return `${verb} command`; } return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); From c78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:07:42 +0300 Subject: [PATCH 002/136] fix(server): isolate remote web session cookies (#8085) Co-authored-by: Julius Marminge --- apps/server/src/auth/EnvironmentAuth.test.ts | 16 +++ apps/server/src/auth/EnvironmentAuth.ts | 50 +++++++-- .../src/auth/EnvironmentAuthAdmin.test.ts | 2 + .../src/auth/EnvironmentAuthPolicy.test.ts | 6 +- apps/server/src/auth/EnvironmentAuthPolicy.ts | 3 + apps/server/src/auth/SessionStore.test.ts | 37 ++++++- apps/server/src/auth/SessionStore.ts | 12 ++- apps/server/src/auth/http.ts | 49 ++++++--- apps/server/src/auth/utils.test.ts | 65 +++++++---- apps/server/src/auth/utils.ts | 35 ++++-- apps/server/src/bin.test.ts | 29 ++++- apps/server/src/cli/connect.ts | 5 +- apps/server/src/cli/pair.test.ts | 25 ++++- .../src/environment/ServerEnvironment.test.ts | 77 ++++++++++++- .../src/environment/ServerEnvironment.ts | 102 ++++++++++++++---- apps/server/src/server.test.ts | 47 ++++++++ apps/server/src/server.ts | 7 +- docs/internals/remote.md | 4 + 18 files changed, 484 insertions(+), 87 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 440efcee51eed..6e5f22fa3af6c 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -34,6 +35,7 @@ const makeEnvironmentAuthLayer = (overrides?: Partial { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("prefers a bearer token over a stale legacy cookie", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const bearer = yield* serverAuth.issueSession(); + const verified = yield* serverAuth.authenticateHttpRequest({ + cookies: { [sessions.legacyCookieName ?? "t3_session"]: "stale" }, + headers: { authorization: `Bearer ${bearer.token}` }, + } as never); + + expect(verified.sessionId).toBe(bearer.sessionId); + }).pipe(Effect.provide(makeEnvironmentAuthLayer({ mode: "web", host: "192.168.1.50" }))), + ); + it.effect("does not exchange ordinary pairing grants for administrative access tokens", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index f5d244dd96671..08838cb7b7800 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -30,6 +30,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -562,6 +563,34 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | return token.length > 0 ? token : null; } +export function selectRequestCredential( + request: HttpServerRequest.HttpServerRequest, + cookieName: string, + legacyCookieName: string | undefined, +) { + const cookieToken = request.cookies[cookieName]; + if (cookieToken !== undefined) { + return { token: cookieToken, source: "cookie" } as const; + } + + const bearerToken = parseBearerToken(request); + if (bearerToken !== null) { + return { token: bearerToken, source: "bearer" } as const; + } + + const dpopToken = parseDpopToken(request); + if (dpopToken !== null) { + return { token: dpopToken, source: "dpop" } as const; + } + + const legacyToken = legacyCookieName ? request.cookies[legacyCookieName] : undefined; + if (legacyToken !== undefined) { + return { token: legacyToken, source: "legacy-cookie" } as const; + } + + return undefined; +} + export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -600,17 +629,19 @@ export const make = Effect.gen(function* () { const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, ): Effect.Effect => { - const cookieToken = request.cookies[sessions.cookieName]; - const bearerToken = parseBearerToken(request); - const dpopToken = parseDpopToken(request); - const credential = cookieToken ?? bearerToken ?? dpopToken; - if (!credential) { + const credential = selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } - return authenticateToken(credential).pipe( + const dpopToken = parseDpopToken(request); + return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { - if (!dpopToken || dpopToken !== credential) { + if (!dpopToken || dpopToken !== credential.token) { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound access token requires DPoP authorization.", @@ -1003,4 +1034,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe( export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); -export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer)); +export const runtimeLayer = layer.pipe( + Layer.provideMerge(storageLayer), + Layer.provideMerge(ServerEnvironment.identityLayer), +); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 03009270e15ca..331a722534b43 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -35,6 +36,7 @@ const makeEnvironmentAuthLayer = ( EnvironmentAuth.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(makeServerConfigLayer(overrides)), ); diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 8e4c21710880f..982ff397db402 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -4,12 +4,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; const makeEnvironmentAuthPolicyLayer = ( overrides?: Partial, ) => EnvironmentAuthPolicy.layer.pipe( + Layer.provide(ServerEnvironment.identityLayer), Layer.provide( Layer.effect( ServerConfig.ServerConfig, @@ -107,7 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("remote-reachable"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ @@ -143,7 +145,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { const descriptor = yield* policy.getDescriptor(); expect(descriptor.policy).toBe("remote-reachable"); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 9945c69067d74..446b8a8bba95e 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; export class EnvironmentAuthPolicy extends Context.Service< @@ -15,6 +16,7 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const isRemoteReachable = isRemoteReachableHost(config.host); const policy = @@ -42,6 +44,7 @@ export const make = Effect.gen(function* () { port: config.port, host: config.host, instanceKey: config.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: config.devUrl !== undefined, }), }; diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1fb01c1f00027..aa3b2d199148e 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -7,15 +8,14 @@ import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -const makeServerConfigLayer = ( - overrides?: Partial>, -) => +const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( ServerConfig.ServerConfig, Effect.gen(function* () { @@ -27,12 +27,19 @@ const makeServerConfigLayer = ( }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); +const makeServerEnvironmentLayer = (environmentId: EnvironmentId) => + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(environmentId), + }); + const makeSessionStoreLayer = ( - overrides?: Partial>, + overrides?: Partial, + environmentId = EnvironmentId.make("test-environment"), ) => SessionStore.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide(makeServerEnvironmentLayer(environmentId)), Layer.provide(makeServerConfigLayer(overrides)), ); @@ -58,10 +65,32 @@ const failingSessionLookupCredentialLayer = Layer.effect( Layer.provide(failingSessionLookupRepositoryLayer), Layer.provide(ServerSecretStore.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))), Layer.provide(makeServerConfigLayer()), ); it.layer(NodeServices.layer)("SessionStore.layer", (it) => { + it.effect("keys remote cookies by environment identity instead of state directory", () => + Effect.gen(function* () { + const cookieName = (stateDir: string, environmentId: EnvironmentId) => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + return sessions.cookieName; + }).pipe( + Effect.provide( + makeSessionStoreLayer({ mode: "web", host: "192.168.1.50", stateDir }, environmentId), + ), + ); + + const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one")); + const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one")); + const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two")); + + expect(moved).toBe(original); + expect(other).not.toBe(original); + }), + ); + it.effect("issues and verifies signed browser session tokens", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index cdcd4a1ac1989..d4fbe445edf6b 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -21,11 +21,13 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, + resolveLegacySessionCookieName, resolveSessionCookieName, signPayload, timingSafeEqualBase64Url, @@ -360,6 +362,7 @@ export class SessionStore extends Context.Service< SessionStore, { readonly cookieName: string; + readonly legacyCookieName: string | undefined; readonly issue: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; @@ -470,18 +473,22 @@ function toAuthClientSession(input: Omit): AuthCli export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const secretStore = yield* ServerSecretStore.ServerSecretStore; const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); - const cookieName = resolveSessionCookieName({ + const cookieInput = { mode: serverConfig.mode, port: serverConfig.port, host: serverConfig.host, instanceKey: serverConfig.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: serverConfig.devUrl !== undefined, - }); + } as const; + const cookieName = resolveSessionCookieName(cookieInput); + const legacyCookieName = resolveLegacySessionCookieName(cookieInput); const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { @@ -930,6 +937,7 @@ export const make = Effect.gen(function* () { return SessionStore.of({ cookieName, + legacyCookieName, issue, verify, issueWebSocketToken, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 58277141a946b..cc74966c41e24 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, }); } +const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) => + Effect.fromResult( + Cookies.set(Cookies.empty, cookieName, token, { + expires: DateTime.toDate(expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe( + Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")), + Effect.flatMap((cookies) => + HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.mergeCookies(response, cookies)), + ), + ), + ); + export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( scope: AuthEnvironmentScope, ) { @@ -224,7 +241,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); const request = yield* HttpServerRequest.HttpServerRequest; - return yield* serverAuth.getSessionState(request); + const result = yield* serverAuth.getSessionState(request); + const credential = EnvironmentAuth.selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if ( + credential?.source === "legacy-cookie" && + result.authenticated && + result.sessionMethod === "browser-session-cookie" && + result.expiresAt + ) { + yield* appendSessionCookie(sessions.cookieName, credential.token, result.expiresAt); + yield* appendCredentialResponseHeaders; + } + return result; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -241,17 +273,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); - const sessionCookies = yield* Effect.fromResult( - Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { - expires: DateTime.toDate(result.response.expiresAt), - httpOnly: true, - path: "/", - sameSite: "lax", - }), - ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); - - yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), + yield* appendSessionCookie( + sessions.cookieName, + result.sessionToken, + result.response.expiresAt, ); yield* appendCredentialResponseHeaders; return result.response; diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index edc58f71131fc..aebc9df5f4371 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -64,6 +64,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-one", + environmentId: "environment-one", development: true, }); const second = resolveSessionCookieName({ @@ -71,6 +72,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-two", + environmentId: "environment-two", development: true, }); @@ -79,25 +81,48 @@ describe("session cookie isolation", () => { expect(first).not.toBe(second); }); - it("keeps the hosted web cookie stable across server instances", () => { - expect( - resolveSessionCookieName({ - mode: "web", - port: 8080, - host: "0.0.0.0", - instanceKey: "/srv/release-a", - development: false, - }), - ).toBe("t3_session"); - expect( - resolveSessionCookieName({ - mode: "web", - port: 9090, - host: "app.example.com", - instanceKey: "/srv/release-b", - development: false, - }), - ).toBe("t3_session"); + it("isolates remote web servers by server state", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 3773, + host: "192.168.1.50", + instanceKey: "/srv/t3-one", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "192.168.1.50", + instanceKey: "/srv/t3-two", + environmentId: "environment-two", + development: false, + }); + + expect(first).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(second).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(first).not.toBe(second); + }); + + it("keeps a remote web server cookie stable across port changes", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 8080, + host: "0.0.0.0", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 9090, + host: "app.example.com", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + + expect(first).toBe(second); }); it("retains desktop port scoping", () => { @@ -107,6 +132,7 @@ describe("session cookie isolation", () => { port: 3773, host: "127.0.0.1", instanceKey: "/tmp/desktop", + environmentId: "environment-one", development: true, }), ).toBe("t3_session_3773"); @@ -119,6 +145,7 @@ describe("session cookie isolation", () => { port: 5775, host: "0.0.0.0", instanceKey: "/tmp/t3-wildcard-dev", + environmentId: "environment-one", development: true, }), ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 32a6799b01f40..30d59d6540106 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -16,40 +16,53 @@ const SESSION_COOKIE_NAME = "t3_session"; * clobbers the first's session and both sides see "Invalid session token * signature" until someone clears cookies by hand. * - * Two populations qualify, for the same reason but from different causes: + * Remote web servers use their persisted environment identity and omit the + * port, so the name survives state-directory moves and public port changes. * - * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. - * - **Desktop**, which scans upward from 3773 for a free port and binds + * Desktop scans upward from 3773 for a free port and binds * 127.0.0.1, so a second instance lands on a different port and the same host. - * - * Hosted deployments keep the stable production name: their public port can - * change between releases, and scoping it would log every user out. */ export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; readonly host: string | undefined; readonly instanceKey: string; + readonly environmentId: string; readonly development: boolean; }): string { if (input.mode === "desktop") { return `${SESSION_COOKIE_NAME}_${input.port}`; } + const instanceHash = NodeCrypto.createHash("sha256") + .update( + !input.development && isRemoteReachableHost(input.host) + ? input.environmentId + : input.instanceKey, + ) + .digest("hex") + .slice(0, 12); + if (!input.development && isRemoteReachableHost(input.host)) { - return SESSION_COOKIE_NAME; + return `${SESSION_COOKIE_NAME}_${instanceHash}`; } // Cookies are scoped by host, not port. Loopback development servers need an // instance-specific name or parallel agents overwrite each other's session, // and a server that later reuses the port receives a token signed elsewhere. - const instanceHash = NodeCrypto.createHash("sha256") - .update(input.instanceKey) - .digest("hex") - .slice(0, 12); return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; } +export function resolveLegacySessionCookieName(input: { + readonly mode: "web" | "desktop"; + readonly host: string | undefined; + readonly development: boolean; +}): string | undefined { + return input.mode === "web" && !input.development && isRemoteReachableHost(input.host) + ? SESSION_COOKIE_NAME + : undefined; +} + export function isRemoteReachableHost(host: string | undefined): boolean { if (host === "0.0.0.0" || host === "::" || host === "[::]") { return true; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index fcb662b9b780f..4cccfc6a1a7af 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -13,6 +13,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -26,7 +27,13 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; +import { + SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_LAUNCHER_PROTOCOL, +} from "./cloud/serviceProtocol.ts"; import * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; @@ -42,7 +49,24 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import packageJson from "../package.json" with { type: "json" }; + const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const DisconnectedLauncherChildLayer = Layer.mergeAll( + Layer.succeed(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Layer.succeed(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), +); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); @@ -127,6 +151,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef Layer.provideMerge( EnvironmentAuth.layer.pipe( Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(ServerSecretStore.layer), ), ), @@ -237,7 +262,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(status.linked, false); assert.equal(status.cloudUserId, null); assert.equal(status.relayUrl, null); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("reports actionable human-readable headless connect state", () => @@ -408,7 +433,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { "relay:write", ]); assert.equal("token" in (listed[0] ?? {}), false); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("rejects invalid ttl values before running auth commands", () => diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 74f469364aee4..3f8e1d123da54 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -337,7 +337,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f return { status: "not-authenticated" } satisfies RelayUnlinkResult; } - const environment = yield* ServerEnvironment.ServerEnvironment; + const environment = yield* ServerEnvironment.ServerEnvironmentIdentity; const environmentId = yield* environment.getEnvironmentId; const relayUrl = yield* relayUrlConfig; const httpClient = yield* HttpClient.HttpClient; @@ -432,7 +432,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* , options?: { readonly quietLogs?: boolean; @@ -449,7 +449,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* { assert.equal(credentials.length, 1); assert.equal(credentials[0]?.label, "t3 pair"); }), - ).pipe(Effect.provide(NodeServices.layer)), + ).pipe( + Effect.provide(NodeServices.layer), + Effect.provideService(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), + ), ); it.effect("pairs through the recorded dev web URL for dev servers", () => diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 9dc1a8eb5b0c6..32006f691ed7f 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -71,6 +73,77 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { }); it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect.each([ + { name: "missing", content: undefined }, + { name: "empty", content: "" }, + { name: "whitespace-only", content: " \t\n" }, + ])("concurrent initializers recover a $name environment id file", ({ content }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-concurrent-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + if (content !== undefined) { + yield* fileSystem.writeFileString(serverConfig.environmentIdPath, content); + } + const bothGenerated = yield* Deferred.make(); + const bothReadEmpty = yield* Deferred.make(); + const firstInitialized = yield* Deferred.make(); + let remaining = 2; + let emptyReads = 0; + const readIdentity = Effect.gen(function* () { + const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; + return yield* identity.getEnvironmentId; + }).pipe( + Effect.tap(() => Deferred.succeed(firstInitialized, undefined)), + Effect.provide(Layer.fresh(ServerEnvironment.identityLayer)), + Effect.provideService(ServerConfig.ServerConfig, serverConfig), + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readFileString: (path) => + fileSystem.readFileString(path).pipe( + Effect.tap( + Effect.fn(function* (value) { + if (path !== serverConfig.environmentIdPath || remaining > 0 || value.trim()) { + return; + } + // Both observe the empty file, but one repairs it after the other has finished. + if (++emptyReads === 2) { + yield* Deferred.succeed(bothReadEmpty, undefined); + yield* Deferred.await(firstInitialized); + } else { + yield* Deferred.await(bothReadEmpty); + } + }), + ), + ), + }), + Effect.provideService(Crypto.Crypto, { + ...crypto, + randomUUIDv4: Effect.gen(function* () { + const id = yield* crypto.randomUUIDv4; + if (--remaining === 0) { + yield* Deferred.succeed(bothGenerated, undefined); + } + yield* Deferred.await(bothGenerated); + return id; + }), + }), + ); + + const [first, second] = yield* Effect.all([readIdentity, readIdentity], { + concurrency: "unbounded", + }); + const persisted = yield* fileSystem.readFileString(serverConfig.environmentIdPath); + + expect(first).toBe(second); + expect(persisted.trim()).toBe(first); + }), + ); + it.effect("persists the environment id across service restarts", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -153,6 +226,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }); const serverConfig = yield* makeServerConfig(baseDir); const environmentIdPath = serverConfig.environmentIdPath; + const tempPath = `${environmentIdPath}.tmp`; const methodByOperation = { check: "exists", read: "readFileString", @@ -172,6 +246,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { exists: () => operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"), readFileString: () => Effect.fail(cause), + makeTempFileScoped: () => Effect.succeed(tempPath), writeFileString: (path) => { writeAttempts.push(path); return Effect.fail(cause); @@ -201,7 +276,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(error.message).toBe( `Server environment ID ${operation} failed at '${environmentIdPath}'.`, ); - expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []); + expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []); } }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9c3a0d2a9637c..11d4078de60ea 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -24,12 +24,15 @@ import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( "ServerEnvironmentIdPersistenceError", { - operation: Schema.Literals(["check", "read", "write"]), + operation: Schema.Literals(["check", "read", "write", "initialize"]), environmentIdPath: Schema.String, - cause: Schema.Defect(), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { + if (this.operation === "initialize") { + return `Server environment ID file is missing or empty after initialization at '${this.environmentIdPath}'.`; + } return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`; } } @@ -42,6 +45,13 @@ export class ServerEnvironment extends Context.Service< } >()("t3/environment/ServerEnvironment") {} +export class ServerEnvironmentIdentity extends Context.Service< + ServerEnvironmentIdentity, + { + readonly getEnvironmentId: Effect.Effect; + } +>()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {} + function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { switch (platform) { case "darwin": @@ -68,14 +78,10 @@ function platformArch( } } -export const make = Effect.gen(function* () { +const makeIdentity = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; - const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( @@ -107,17 +113,42 @@ export const make = Effect.gen(function* () { return raw.length > 0 ? raw : null; }); - const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe( - Effect.mapError( - (cause) => - new ServerEnvironmentIdPersistenceError({ - operation: "write", - environmentIdPath: serverConfig.environmentIdPath, - cause, - }), - ), - ); + const persistEnvironmentId = Effect.fn("ServerEnvironmentIdentity.persistEnvironmentId")( + function* (value: string, mode: "create" | "recover") { + const destinationPath = + mode === "recover" + ? `${serverConfig.environmentIdPath}.recovery` + : serverConfig.environmentIdPath; + const tempPath = yield* fileSystem.makeTempFileScoped({ + directory: serverConfig.stateDir, + prefix: ".environment-id-", + }); + yield* fileSystem.writeFileString(tempPath, `${value}\n`); + // Publish the completed file without replacing an ID created by another process. + yield* fileSystem + .link(tempPath, destinationPath) + .pipe( + Effect.catch((cause) => + cause.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(cause), + ), + ); + if (mode === "recover") { + // Keep the recovery ID so delayed initializers also publish the same winner. + yield* fileSystem.remove(tempPath); + yield* fileSystem.copyFile(destinationPath, tempPath); + yield* fileSystem.rename(tempPath, serverConfig.environmentIdPath); + } + }, + Effect.scoped, + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "write", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); const environmentIdRaw = yield* Effect.gen(function* () { const persisted = yield* readPersistedEnvironmentId; @@ -126,11 +157,35 @@ export const make = Effect.gen(function* () { } const generated = yield* crypto.randomUUIDv4; - yield* persistEnvironmentId(generated); - return generated; + yield* persistEnvironmentId(generated, "create"); + let winner = yield* readPersistedEnvironmentId; + if (winner === null) { + yield* persistEnvironmentId(generated, "recover"); + winner = yield* readPersistedEnvironmentId; + } + if (winner === null) { + return yield* new ServerEnvironmentIdPersistenceError({ + operation: "initialize", + environmentIdPath: serverConfig.environmentIdPath, + }); + } + return winner; }); const environmentId = EnvironmentId.make(environmentIdRaw); + return ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(environmentId), + }); +}); + +export const make = Effect.gen(function* () { + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const identity = yield* ServerEnvironmentIdentity; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); const launcher = yield* resolveServiceLauncherMode(); @@ -179,10 +234,15 @@ export const make = Effect.gen(function* () { }); }); +export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentity); + /** * ServerEnvironment is acquired from persisted filesystem and host-process * state. It intentionally has no fallback Layer.succeed value: callers must * provide the external platform services, a ServerConfig, and the * ServerSecretStore backing the descriptor's publishing capability. */ -export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); +export const layer = Layer.effect(ServerEnvironment, make).pipe( + Layer.provideMerge(identityLayer), + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 15c16930350ea..0780af4d1b4aa 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -291,6 +291,11 @@ const makeAuthTestLayer = () => EnvironmentAuth.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + }), + ), ); const makeBrowserOtlpPayload = (spanName: string) => @@ -1674,6 +1679,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("migrates a valid legacy remote-web session cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const currentCookie = cookie?.split(";")[0] ?? ""; + const legacyCookie = currentCookie.replace(/^t3_session_[^=]+=/, "t3_session="); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: { cookie: legacyCookie }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.equal(response.headers["set-cookie"], cookie); + assert.equal(response.headers["cache-control"], "no-store"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect.each(["cookie", "bearer"])( + "does not migrate a stale legacy cookie when %s auth succeeds", + (source) => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const sessionCookie = cookie?.split(";")[0] ?? ""; + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: + source === "cookie" + ? { cookie: `${sessionCookie}; t3_session=stale` } + : { authorization: `Bearer ${sessionToken}`, cookie: "t3_session=stale" }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.isUndefined(response.headers["set-cookie"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8c6d4253acaad..9a05fd18517eb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -362,8 +362,13 @@ const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( Layer.provide(T3ProjectFileLoader.layer), ); +const ServerEnvironmentLayerLive = ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), +); + const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), + Layer.provide(ServerEnvironmentLayerLive), Layer.provide(ServerSecretStore.layer), ); @@ -418,7 +423,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolver.layer), - Layer.provideMerge(ServerEnvironment.layer), + Layer.provideMerge(ServerEnvironmentLayerLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( diff --git a/docs/internals/remote.md b/docs/internals/remote.md index afce95f725bc9..65416a19e9670 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -41,6 +41,10 @@ It is identified by a stable `environmentId`, persisted by the server at `/environment-id.recovery` file so concurrent and delayed repairs choose +the same ID. Existing nonempty ID files remain authoritative. + ### Known environments and connection targets A saved client-side entry for an environment the client knows how to reach. It is not From 42a8fd5103b4af57b16be721a9864ca3a5c18c5b Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 31 Aug 2026 23:35:28 -0400 Subject: [PATCH 003/136] feat(pull-requests): link GitHub references in markdown (#8812) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ChatMarkdown.tsx | 30 ++++++- .../pullRequest/PullRequestDetailPanel.tsx | 5 +- .../pullRequest/PullRequestMarkdown.tsx | 12 ++- .../pullRequest/pullRequestMarkdown.logic.ts | 49 ++++++++++ apps/web/src/vendor/mdast-find-and-replace.ts | 90 +++++++++++++++++++ 5 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/vendor/mdast-find-and-replace.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 7773338a74594..d23d6bef4868c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -179,6 +179,7 @@ interface ChatMarkdownProps { onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + extraRemarkPlugins?: NonNullable; } export function canUseMarkdownFileShellActions( @@ -211,6 +212,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: { } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; +const EMPTY_REMARK_PLUGINS: NonNullable = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, @@ -360,6 +362,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], + a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], }, protocols: { @@ -1807,6 +1810,7 @@ function ChatMarkdown({ onUseArtifactTemplate, imageBaseDir, onImageExpand, + extraRemarkPlugins = EMPTY_REMARK_PLUGINS, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -2203,6 +2207,16 @@ function ChatMarkdown({ : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); + const pullRequestAutolink = String( + (props as Record)["data-pull-request-autolink"] ?? "", + ); + const pullRequestCopy = + pullRequestAutolink === "commit" + ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1] + : pullRequestAutolink === "reference" + ? plainHastText(node) + : undefined; + const isPullRequestAutolink = pullRequestCopy !== undefined; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -2210,6 +2224,8 @@ function ChatMarkdown({ const link = ( - {faviconHost && hastHasText(node) ? ( + {faviconHost && hastHasText(node) && !isPullRequestAutolink ? ( {linkChildren} @@ -2438,6 +2454,14 @@ function ChatMarkdown({ ]); /* eslint-enable react/no-unstable-nested-components */ + const remarkPlugins = useMemo( + () => [ + ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), + ...extraRemarkPlugins, + ], + [extraRemarkPlugins, lineBreaks], + ); + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the // complete source token instead of dropping it from the rendered message. @@ -2450,9 +2474,7 @@ function ChatMarkdown({ onCopy={handleCopy} > ) : detail ? ( - <> + {mountedTabs.has("summary") ? (
) : null} - +
) : null}
diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index f782e5be113dd..ad06eb21e2aea 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,10 +1,14 @@ import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; import type { EnvironmentId } from "@t3tools/contracts"; +import { createContext, useContext, useMemo } from "react"; +import type { Options as ReactMarkdownOptions } from "react-markdown"; import { cn } from "~/lib/utils"; import ChatMarkdown from "../ChatMarkdown"; -import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; +import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; + +export const PullRequestMarkdownContext = createContext(null); /** * A pull request body, rendered with the app's markdown renderer plus a card for each upload @@ -27,6 +31,11 @@ export function PullRequestMarkdown({ className?: string; }) { const segments = splitPullRequestBody(text); + const repositoryUrl = useContext(PullRequestMarkdownContext); + const extraRemarkPlugins = useMemo>( + () => (repositoryUrl ? [[remarkPullRequestAutolinks, { repositoryUrl }]] : []), + [repositoryUrl], + ); return (
{segments.map((segment) => { @@ -37,6 +46,7 @@ export function PullRequestMarkdown({ text={segment.text} cwd={cwd} environmentId={environmentId} + extraRemarkPlugins={extraRemarkPlugins} /> ); } diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index e322b595a1825..79ae60e928cad 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -1,3 +1,9 @@ +import { + findAndReplaceText, + type MarkdownNode, + type TextMatch, +} from "~/vendor/mdast-find-and-replace"; + /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ export type PullRequestBodySegment = | { readonly id: string; readonly kind: "markdown"; readonly text: string } @@ -141,3 +147,46 @@ export function splitPullRequestBody(body: string): ReadonlyArray { + findAndReplaceText( + tree, + AUTOLINK_CANDIDATE_PATTERN, + (matched: string, match: TextMatch) => { + const reference = matched.startsWith("#"); + const before = match.input[match.index - 1]; + const after = match.input[match.index + matched.length]; + if ( + (before !== undefined && + (reference + ? AUTOLINK_WORD_CHARACTER_PATTERN.test(before) + : !AUTOLINK_COMMIT_PREFIX_PATTERN.test(before))) || + (after !== undefined && AUTOLINK_WORD_CHARACTER_PATTERN.test(after)) + ) { + return false; + } + return { + type: "link", + url: reference + ? `${repositoryUrl}/issues/${matched.slice(1)}` + : `${repositoryUrl}/commit/${matched}`, + data: { + hProperties: { + dataPullRequestAutolink: reference ? "reference" : "commit", + }, + }, + children: [{ type: "text", value: reference ? matched : matched.slice(0, 7) }], + }; + }, + AUTOLINK_IGNORED_TYPES, + ); + }; +} diff --git a/apps/web/src/vendor/mdast-find-and-replace.ts b/apps/web/src/vendor/mdast-find-and-replace.ts new file mode 100644 index 0000000000000..2f16ab202ba09 --- /dev/null +++ b/apps/web/src/vendor/mdast-find-and-replace.ts @@ -0,0 +1,90 @@ +/* + * Adapted from mdast-util-find-and-replace: + * https://github.com/syntax-tree/mdast-util-find-and-replace/blob/main/lib/index.js + * + * The MIT License + * + * Copyright (c) Titus Wormer + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export type MarkdownNode = { + type: string; + value?: string; + children?: MarkdownNode[]; + url?: string; + data?: unknown; +}; + +export type TextMatch = { + index: number; + input: string; +}; + +/** The dependency-free subset of mdast-util-find-and-replace used by PR autolinks. */ +export function findAndReplaceText( + tree: MarkdownNode, + find: RegExp, + replace: (matched: string, match: TextMatch) => MarkdownNode | false, + ignoredTypes: ReadonlySet, +): void { + visit(tree); + + function visit(node: MarkdownNode): void { + if (node.children === undefined) return; + for (let childIndex = 0; childIndex < node.children.length; childIndex += 1) { + const child = node.children[childIndex]!; + if (ignoredTypes.has(child.type)) continue; + if (child.type !== "text" || child.value === undefined) { + visit(child); + continue; + } + + const replacements: MarkdownNode[] = []; + let start = 0; + let changed = false; + find.lastIndex = 0; + let match = find.exec(child.value); + while (match !== null) { + const position = match.index; + const replacement = replace(match[0], { index: position, input: match.input }); + if (replacement === false) { + find.lastIndex = position + 1; + } else { + if (start < position) { + replacements.push({ type: "text", value: child.value.slice(start, position) }); + } + replacements.push(replacement); + start = position + match[0].length; + changed = true; + } + if (!find.global) break; + match = find.exec(child.value); + } + + if (!changed) continue; + if (start < child.value.length) { + replacements.push({ type: "text", value: child.value.slice(start) }); + } + node.children.splice(childIndex, 1, ...replacements); + childIndex += replacements.length - 1; + } + } +} From b17cc3d1bf0f4a5deee6dd6a470e36970b864a9b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 31 Aug 2026 20:36:53 -0700 Subject: [PATCH 004/136] perf(server): reduce frequency of full tool call output being loaded into memory from db (#8988) --- .../orchestration/Layers/CheckpointReactor.ts | 2 +- .../Layers/ProjectionPipeline.test.ts | 170 ++++++++++++------ .../Layers/ProjectionPipeline.ts | 4 +- .../Layers/ProjectionSnapshotQuery.test.ts | 71 ++++++++ .../Layers/ProjectionSnapshotQuery.ts | 111 +++++++++--- .../Layers/ProviderCommandReactor.ts | 2 +- .../Layers/ProviderRuntimeIngestion.ts | 10 +- .../Services/ProjectionSnapshotQuery.ts | 10 ++ .../Layers/ProjectionThreadActivities.ts | 72 ++++++-- .../Services/ProjectionThreadActivities.ts | 9 + 10 files changed, 363 insertions(+), 98 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 95adee0cf7f8b..dd9d397200e7c 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -164,7 +164,7 @@ const make = Effect.gen(function* () { const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds: [] }) .pipe(Effect.map(Option.getOrUndefined)); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 57215803f4b92..b8f65a54b92ef 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2235,7 +2235,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("clears stale pending user input from projected shell summaries", () => + it.effect("reads only user-input activities when refreshing shell summaries", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2293,70 +2293,128 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + // Invalid JSON proves the summary query filters tool rows before decoding payloads. + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-malformed-tool-output', + 'thread-stale-user-input', + NULL, + 'info', + 'tool.completed', + 'Tool completed', + '{not-json', + NULL, + '2026-02-26T12:35:02.000Z' + ), + ( + 'activity-user-input-resolved-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-resolved'), + NULL, + '2026-02-26T12:35:03.000Z' + ), + ( + 'activity-user-input-resolved', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.resolved', + 'User input resolved', + json_object('requestId', 'user-input-resolved'), + NULL, + '2026-02-26T12:35:04.000Z' + ), + ( + 'activity-user-input-stale-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-stale'), + NULL, + '2026-02-26T12:35:05.000Z' + ), + ( + 'activity-user-input-stale-failed', + 'thread-stale-user-input', + NULL, + 'error', + 'provider.user-input.respond.failed', + 'Provider user input response failed', + json_object( + 'requestId', + 'user-input-stale', + 'detail', + 'Unknown pending Codex user input request: user-input-stale' + ), + NULL, + '2026-02-26T12:35:06.000Z' + ), + ( + 'activity-user-input-active-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-active'), + NULL, + '2026-02-26T12:35:07.000Z' + ), + ( + 'activity-user-input-active-failed', + 'thread-stale-user-input', + NULL, + 'error', + 'provider.user-input.respond.failed', + 'Provider user input response failed', + json_object( + 'requestId', + 'user-input-active', + 'detail', + 'Provider is temporarily unavailable' + ), + NULL, + '2026-02-26T12:35:08.000Z' + ) + `; + yield* appendAndProject({ - type: "thread.activity-appended", + type: "thread.message-sent", eventId: EventId.make("evt-stale-user-input-3"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:02.000Z", + occurredAt: "2026-02-26T12:35:09.000Z", commandId: CommandId.make("cmd-stale-user-input-3"), causationEventId: null, correlationId: CorrelationId.make("cmd-stale-user-input-3"), metadata: {}, payload: { threadId: ThreadId.make("thread-stale-user-input"), - activity: { - id: EventId.make("activity-stale-user-input-requested"), - tone: "info", - kind: "user-input.requested", - summary: "User input requested", - payload: { - requestId: "user-input-request-stale-1", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - }, - ], - }, - turnId: null, - createdAt: "2026-02-26T12:35:02.000Z", - }, - }, - }); - - yield* appendAndProject({ - type: "thread.activity-appended", - eventId: EventId.make("evt-stale-user-input-4"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:03.000Z", - commandId: CommandId.make("cmd-stale-user-input-4"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-stale-user-input-4"), - metadata: {}, - payload: { - threadId: ThreadId.make("thread-stale-user-input"), - activity: { - id: EventId.make("activity-stale-user-input-failed"), - tone: "error", - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - payload: { - requestId: "user-input-request-stale-1", - detail: - "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: user-input-request-stale-1", - }, - turnId: null, - createdAt: "2026-02-26T12:35:03.000Z", - }, + messageId: MessageId.make("message-stale-user-input"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: "2026-02-26T12:35:09.000Z", + updatedAt: "2026-02-26T12:35:09.000Z", }, }); @@ -2367,7 +2425,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { FROM projection_threads WHERE thread_id = 'thread-stale-user-input' `; - assert.deepEqual(threadRows, [{ pendingUserInputCount: 0 }]); + assert.deepEqual(threadRows, [{ pendingUserInputCount: 1 }]); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ac514d7eb2823..672336816727f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -130,7 +130,7 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { ); } -// A full refresh loads all thread history, so skip events that cannot change the summary. +// A refresh reads each persisted summary source, so skip events that cannot change the result. function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { if (event.type === "thread.message-sent") { return event.payload.role === "user"; @@ -588,7 +588,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ projectionThreadMessageRepository.listByThreadId({ threadId }), projectionThreadProposedPlanRepository.listByThreadId({ threadId }), - projectionThreadActivityRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), projectionPendingApprovalRepository.listByThreadId({ threadId }), ]); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 30892c760e777..0a375c7629148 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -484,6 +484,77 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (threadDetail._tag === "Some") { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + created_at + ) + VALUES + ( + 'activity-task-started', + 'thread-1', + 'turn-1', + 'info', + 'task.started', + 'Ship the query filter', + '{"taskId":"task-1","detail":"Ship the query filter"}', + '2026-02-24T00:00:06.100Z' + ), + ( + 'activity-malformed-tool', + 'thread-1', + 'turn-1', + 'info', + 'tool.completed', + 'Malformed tool output', + 'not-json', + '2026-02-24T00:00:06.200Z' + ) + `; + + const detailWithoutActivities = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + { activityKinds: [] }, + ); + assert.equal(detailWithoutActivities._tag, "Some"); + if (detailWithoutActivities._tag === "Some") { + assert.deepEqual(detailWithoutActivities.value.activities, []); + assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages); + assert.deepEqual( + detailWithoutActivities.value.proposedPlans, + snapshot.threads[0]?.proposedPlans, + ); + assert.deepEqual( + detailWithoutActivities.value.checkpoints, + snapshot.threads[0]?.checkpoints, + ); + } + + const detailWithTaskActivities = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + { activityKinds: ["task.started", "task.progress"] }, + ); + assert.equal(detailWithTaskActivities._tag, "Some"); + if (detailWithTaskActivities._tag === "Some") { + assert.deepEqual(detailWithTaskActivities.value.activities, [ + { + id: asEventId("activity-task-started"), + tone: "info", + kind: "task.started", + summary: "Ship the query filter", + payload: { taskId: "task-1", detail: "Ship the query filter" }, + turnId: asTurnId("turn-1"), + createdAt: "2026-02-24T00:00:06.100Z", + }, + ]); + } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 0b9698eaf9cfc..d3270b0ce5215 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -64,6 +64,7 @@ import { type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, + type ProjectionThreadDetailQuery, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; @@ -141,6 +142,10 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +const ThreadActivityKindsLookupInput = Schema.Struct({ + threadId: ThreadId, + activityKinds: Schema.Array(Schema.String), +}); // Windowed reads order turns by the stable keyset (anchor, turn key), where // anchor is requested_at and turn key is // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the @@ -1055,6 +1060,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ + Request: ThreadActivityKindsLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, activityKinds }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ${sql.in("kind", activityKinds)} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities + ORDER BY + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const getThreadSessionRowByThread = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadSessionDbRowSchema, @@ -2524,8 +2571,43 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly beforeTurnKey: string; } - const getThreadDetailByIdBounded = (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) => + const getThreadDetailByIdBounded = ( + threadId: ThreadId, + bounds: ThreadDetailBounds | undefined, + query: ProjectionThreadDetailQuery | undefined = undefined, + ) => Effect.gen(function* () { + const activityRowsEffect = ( + query?.activityKinds === undefined + ? bounds === undefined + ? listThreadActivityRowsByThread({ threadId }) + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) + : query.activityKinds.length === 0 + ? Effect.succeed([]) + : listThreadActivityRowsByThreadAndKinds({ + threadId, + activityKinds: query.activityKinds, + }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), + ), + ); + const pinnedActivityRowsEffect = + query?.activityKinds === undefined + ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ) + : Effect.succeed([]); + const [ threadRow, messageRows, @@ -2563,25 +2645,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - (bounds === undefined - ? listThreadActivityRowsByThread({ threadId }) - : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) - ).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", - ), - ), - ), - listPinnedThreadActivityRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", - ), - ), - ), + activityRowsEffect, + pinnedActivityRowsEffect, listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2700,8 +2765,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); - const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => - getThreadDetailByIdBounded(threadId, undefined); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( + threadId, + query, + ) => getThreadDetailByIdBounded(threadId, undefined, query); // Bounds pathological fan-out: one user turn that spawned hundreds of // subagent turns still pages in bounded chunks, at the cost of splitting the diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 22c70094ce0e3..a37f0958dd55c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -481,7 +481,7 @@ const make = Effect.gen(function* () { const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds: [] }) .pipe(Effect.map(Option.getOrUndefined)); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7ec3a7e642432..198f163d8a891 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -48,6 +48,7 @@ import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; +const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const; // Fallback when the in-memory description cache no longer has the task name // (server restart, session-exit sweep, TTL/capacity eviction): earlier @@ -949,9 +950,12 @@ const make = Effect.gen(function* () { ), ); - const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* ( + threadId: ThreadId, + activityKinds: ReadonlyArray = [], + ) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds }) .pipe(Effect.map(Option.getOrUndefined)); }); @@ -2022,7 +2026,7 @@ const make = Effect.gen(function* () { if (event.type === "task.completed") { taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); if (!taskTitle) { - const threadDetail = yield* getLoadedThreadDetail(); + const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS); taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); } } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 0a00253a2285b..f783d4d07bba9 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -54,6 +54,15 @@ export interface ProjectionFullThreadDiffContext { readonly toCheckpointRef: CheckpointRef | null; } +export interface ProjectionThreadDetailQuery { + /** + * Limit activities before SQLite returns and decodes their payloads. + * Any explicit filter omits pinned-request reads. An empty list also skips + * the activity query. Omit this option to preserve the full detail response. + */ + readonly activityKinds?: ReadonlyArray; +} + /** * ProjectionSnapshotQueryShape - Service API for read-model snapshots. */ @@ -168,6 +177,7 @@ export interface ProjectionSnapshotQueryShape { */ readonly getThreadDetailById: ( threadId: ThreadId, + query?: ProjectionThreadDetailQuery, ) => Effect.Effect, ProjectionRepositoryError>; /** diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 2f4815f965451..fa3c948e4f3db 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -23,6 +23,21 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( }), ); +const mapActivityRows = ( + rows: ReadonlyArray>, +): ReadonlyArray => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })); + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown) => Schema.isSchemaError(cause) @@ -97,6 +112,36 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const listUserInputLifecycleActivityRows = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const deleteProjectionThreadActivityRows = SqlSchema.void({ Request: DeleteProjectionThreadActivitiesInput, execute: ({ threadId }) => @@ -124,21 +169,21 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map((rows) => - rows.map((row) => ({ - activityId: row.activityId, - threadId: row.threadId, - turnId: row.turnId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - })), - ), + Effect.map(mapActivityRows), ); + const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] = + (input) => + listUserInputLifecycleActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:query", + "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:decodeRows", + ), + ), + Effect.map(mapActivityRows), + ); + const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadActivityRows(input).pipe( Effect.mapError( @@ -149,6 +194,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { return { upsert, listByThreadId, + listUserInputLifecycleByThreadId, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 47cb6073c479a..e8c1e47a328b3 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -67,6 +67,15 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * List activity rows used to derive pending user-input state. + * + * Filters in SQLite so unrelated payloads do not enter server memory. + */ + readonly listUserInputLifecycleByThreadId: ( + input: ListProjectionThreadActivitiesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Delete projected thread activity rows by thread. */ From d35c71d1b975660ec6a71bbceb6aa7dfd0e8c3d5 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 00:16:57 -0400 Subject: [PATCH 005/136] feat(web): add pull request list filters (#8809) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../PullRequestListFilters.test.tsx | 7 +- .../pullRequest/PullRequestListFilters.tsx | 432 +++++++++++++----- .../components/pullRequest/PullRequestRow.tsx | 20 +- .../pullRequest/PullRequestSummaryTab.tsx | 9 +- .../pullRequest/pullRequestList.logic.ts | 74 ++- apps/web/src/routes/_chat.pull-requests.tsx | 194 +++++++- docs/user/source-control.md | 3 + 7 files changed, 585 insertions(+), 154 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx index f1c3013167f7f..7ec2629c77b33 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx @@ -34,7 +34,8 @@ function findLabeledGroup(node: ReactNode, label: string): ReactNode { if (!isValidElement(child)) continue; const props = child.props as { readonly children?: ReactNode; readonly label?: string }; if (props.label === label && typeof child.type === "function") { - return (child.type as (properties: unknown) => ReactNode)(child.props); + const rendered = (child.type as (properties: unknown) => ReactNode)(child.props); + return findLabeledGroup(rendered, label) ?? rendered; } const nested = findLabeledGroup(props.children, label); if (nested !== undefined) return nested; @@ -126,7 +127,7 @@ describe("pull request filters menu", () => { projectEnvironmentId: environmentId, onProject, }); - const radioGroup = findValueChange(view); + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); expect(radioGroup).toBeDefined(); radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId })); @@ -156,7 +157,7 @@ describe("pull request filters menu", () => { ], onProject, }); - const radioGroup = findValueChange(view); + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); expect(radioGroup).toBeDefined(); radioGroup?.props.onValueChange( diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 67d2d77e4c946..9c3bfbab0c19f 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -18,10 +18,11 @@ import { ListFilterIcon, LoaderIcon, SearchIcon, + TagIcon, + UserRoundIcon, } from "lucide-react"; -import type { ElementType } from "react"; +import { type ElementType, useState } from "react"; -import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; @@ -29,27 +30,56 @@ import { Button } from "../ui/button"; import { Menu, + MenuCheckboxItem, MenuGroupLabel, + MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, MenuTrigger, } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + pullRequestLabelColor, + type PullRequestAuthorFacet, + type PullRequestLabelFacet, +} from "./pullRequestList.logic"; +import { PullRequestActorAvatar } from "./pullRequestPresentation"; export interface PullRequestFilterOption { readonly value: Value; readonly label: string; - /** - * Carries the option's own tone, so an icon reads the same here as it does on a row. Left - * uncoloured, which lets the item's selected state stay the thing the eye follows. - */ + /** Uses the option's native icon tone. */ readonly Icon: ElementType<{ className?: string }>; + readonly favicon?: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + }; /** Why it cannot be chosen, carried onto the item as its title. */ readonly unavailable?: string | undefined; } +export function PullRequestFilterOptionIcon({ + option, +}: { + option: PullRequestFilterOption; +}) { + return option.favicon ? ( + + ) : ( + + ); +} + export interface PullRequestExpectedHost { readonly host: string; readonly kind: SourceControlProviderKind; @@ -98,10 +128,8 @@ export function PullRequestSearchInput({ } /** - * Every list filter lives behind the one filter icon so the control row stays two controls - * wide: the search and this. The trigger carries a dot whenever any filter is off its - * default, so a narrowed list is never a mystery. Same menu chrome as the detail panel's - * actions, which also owns its own spacing. + * List narrowings live behind one filter control, separate from sorting. The trigger carries a + * count whenever any filter is off its default, so a narrowed list is never a mystery. */ const ALL_PROJECTS_VALUE = "all"; /** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */ @@ -169,8 +197,9 @@ function PullRequestFilterRadioGroup({ disabled={option.unavailable !== undefined} > - - {option.label} + + {option.label} + {option.unavailable ? · Unavailable : null} ); @@ -188,7 +217,185 @@ function PullRequestFilterRadioGroup({ ); } +function PullRequestFilterRadioSubmenu({ + label, + value, + options, + onChange, +}: { + label: string; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; +}) { + const current = options.find((option) => option.value === value) ?? options[0]; + if (!current) return null; + return ( + + + + {label} + + {current.label} + + + + + + + ); +} + +function PullRequestAuthorFilter({ + value, + options, + onChange, +}: { + value: string | undefined; + options: ReadonlyArray; + onChange: (author: string | undefined) => void; +}) { + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const login = value?.toLowerCase() ?? ""; + const selected = options.find((option) => option.actor.login.toLowerCase() === login); + const visible = [ + ...(selected ? [selected] : []), + ...options.filter( + (option) => + option !== selected && + (needle.length === 0 || + option.actor.login.toLowerCase().includes(needle) || + option.actor.name?.toLowerCase().includes(needle)), + ), + ].slice(0, 10); + const select = (next: string) => next.toLowerCase() !== login && onChange(next || undefined); + return ( + + + + Author + + {value ?? "Anyone"} + + + +
+ + + + + setQuery(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== "ArrowDown" && event.key !== "Escape") event.stopPropagation(); + }} + placeholder="Search authors" + aria-label="Search authors" + /> + +
+ + + + + Anyone + + + {visible.map((option) => ( + + + + {option.actor.login} + + {option.mergedCount} merges loaded + + + + ))} + {visible.length === 0 ? No authors found : null} + +
+
+ ); +} + +function PullRequestLabelFilter({ + value, + options, + onChange, +}: { + value: ReadonlyArray; + options: ReadonlyArray; + onChange: (labels: ReadonlyArray) => void; +}) { + const selected = new Set(value.map((name) => name.toLowerCase())); + const visible = [ + ...value + .filter((name) => !options.some((option) => option.name.toLowerCase() === name.toLowerCase())) + .map((name) => ({ name, color: null, count: 0 })), + ...options, + ]; + return ( + + + + Labels + + {value.length === 0 ? "Any" : `${value.length} selected`} + + + + {visible.length === 0 ? ( + No labels in this view + ) : ( + visible.map((option) => { + const key = option.name.toLowerCase(); + const checked = selected.has(key); + const dot = pullRequestLabelColor(option.color); + return ( + + onChange( + next + ? [...value, option.name] + : value.filter((name) => name.toLowerCase() !== option.name.toLowerCase()), + ) + } + > + + + {option.name} + + {option.count} + + + + ); + }) + )} + + + ); +} + export function PullRequestFiltersMenu({ + onOpenChange, state, stateOptions, onState, @@ -197,6 +404,8 @@ export function PullRequestFiltersMenu({ onInvolvement, filters, onFilters, + authorOptions = [], + labelOptions = [], host, hostOptions, onHost, @@ -209,6 +418,7 @@ export function PullRequestFiltersMenu({ unavailable, onProject, }: { + onOpenChange?: (open: boolean) => void; state: PullRequestListState; stateOptions: ReadonlyArray>; onState: (state: PullRequestListState) => void; @@ -218,6 +428,8 @@ export function PullRequestFiltersMenu({ /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */ filters: PullRequestListFilters; onFilters: (filters: PullRequestListFilters) => void; + authorOptions?: ReadonlyArray; + labelOptions?: ReadonlyArray; host: string | undefined; /** * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real @@ -254,82 +466,119 @@ export function PullRequestFiltersMenu({ /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */ onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void; }) { - const filtered = - state !== "open" || - involvement !== "all" || - host !== undefined || - server !== undefined || - projectId !== undefined || - Object.keys(filters).length > 0; - /** - * Rebuilt rather than spread so an unfiltered group leaves the record instead of lingering in - * it as an explicit `undefined`, which the listing input does not accept. - */ - const withFilter = (key: keyof PullRequestListFilters, value: string): PullRequestListFilters => - Object.fromEntries( - Object.entries({ ...filters, [key]: value === UNFILTERED_VALUE ? undefined : value }).filter( - ([, held]) => held !== undefined, - ), - ) as PullRequestListFilters; + const selectedLabels = (filters.labels ?? []).flatMap((group) => group); + const filterCount = [ + state !== "open", + involvement !== "all", + host, + server, + projectId, + filters.draft, + filters.review, + filters.checks, + filters.author, + ...selectedLabels, + ].filter(Boolean).length; + const updateFilters = (next: Partial) => + onFilters( + Object.fromEntries( + Object.entries({ ...filters, ...next }).filter(([, value]) => value !== undefined), + ) as PullRequestListFilters, + ); + const updateFilter = (key: keyof PullRequestListFilters, value: string) => + updateFilters({ + [key]: value === UNFILTERED_VALUE ? undefined : value, + } as Partial); + const projectValue = + projectId === undefined || projectEnvironmentId === undefined + ? ALL_PROJECTS_VALUE + : pullRequestProjectKey({ id: projectId, environmentId: projectEnvironmentId }); + const projectOptions: ReadonlyArray> = [ + { value: ALL_PROJECTS_VALUE, label: "All projects", Icon: LayersIcon }, + ...projects + .toSorted( + (left, right) => + Number(unavailable.has(pullRequestProjectKey(left))) - + Number(unavailable.has(pullRequestProjectKey(right))), + ) + .map((project) => ({ + value: pullRequestProjectKey(project), + label: project.title, + Icon: FolderGit2Icon, + favicon: { environmentId: project.environmentId, cwd: project.workspaceRoot }, + ...(unavailable.has(pullRequestProjectKey(project)) + ? { unavailable: unavailable.get(pullRequestProjectKey(project)) } + : {}), + })), + ]; return ( -
+ 0 ? "[--control-icon-color:currentColor]" : undefined} variant="outline" - aria-label="Filter pull requests" /> } > - {filtered ? ( - + Filters + {filterCount > 0 ? ( + + {filterCount} + ) : null} - - + - - - updateFilters({ author })} + /> + + updateFilters({ + labels: labels.length === 0 ? undefined : labels.slice(0, 10).map((label) => [label]), + }) + } + /> + onFilters(withFilter("draft", next))} + onChange={(draft) => updateFilter("draft", draft)} /> - - onFilters(withFilter("review", next))} + onChange={(review) => updateFilter("review", review)} /> - - onFilters(withFilter("checks", next))} + onChange={(checks) => updateFilter("checks", checks)} /> {hostOptions.length > 2 ? ( <> - 2 ? ( <> - ) : null} - { - if (next === ALL_PROJECTS_VALUE) { - if (projectId !== undefined) onProject(undefined, undefined); - return; - } - // The value carries both halves, since the id alone cannot tell two servers' rows - // apart once they share one. + { const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next); - if ( - project !== undefined && - (project.id !== projectId || project.environmentId !== projectEnvironmentId) - ) { - onProject(project.id, project.environmentId); - } + if (project) onProject(project.id, project.environmentId); + else if (projectId !== undefined) onProject(undefined, undefined); }} - > - Project - - - - All projects - - - {/* The ones that can be chosen first: a list that opens with three disabled rows reads - as a broken menu rather than as a workspace with three unreadable repositories. */} - {projects - .toSorted( - (left, right) => - Number(unavailable.has(pullRequestProjectKey(left))) - - Number(unavailable.has(pullRequestProjectKey(right))), - ) - .map((project) => { - const reason = unavailable.get(pullRequestProjectKey(project)); - const item = ( - - - - {project.title} - {reason === undefined ? null : ( - - Unavailable - - )} - - - ); - if (reason === undefined) return item; - return ( - - - - {reason} - - - ); - })} - + /> ); diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index c7f731f9be133..b6b96ab7f55ed 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -7,7 +7,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; -import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; +import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; import { PullRequestActorLabel, @@ -16,6 +16,23 @@ import { PullRequestStateGlyph, } from "./pullRequestPresentation"; +function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry["labels"] }) { + const label = labels[0]; + if (!label) return null; + const dot = pullRequestLabelColor(label.color); + return ( + + + {label.name} + {labels.length > 1 ? +{labels.length - 1} : null} + + ); +} + function PullRequestRowImpl({ entry, selected, @@ -118,6 +135,7 @@ function PullRequestRowImpl({ className="min-w-4 max-w-40" labelClassName="sr-only @xs/pr-row-meta:not-sr-only @xs/pr-row-meta:truncate" /> + {entry.labels.length > 0 ? : null} {/* Only a verdict somebody has actually given: "review required" is the absence of one, and saying so on every unreviewed row would say nothing. */} {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index ef3ae062f65a7..7d31ec6e4bab6 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -57,6 +57,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; +import { pullRequestLabelColor } from "./pullRequestList.logic"; import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; /** One reviewer, however a host happens to have cased their login this time. */ @@ -64,12 +65,6 @@ function reviewerKey(login: string): string { return login.toLowerCase(); } -/** A host colour only when it is one, so a malformed value falls back to the neutral dot. */ -function labelDotColor(color: string | null): string | null { - const hex = color?.trim().replace(/^#/, "") ?? ""; - return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; -} - /** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */ function CommentAuthor({ actor }: { actor: PullRequestActor | null }) { const login = actor?.login ?? "ghost"; @@ -619,7 +614,7 @@ export function PullRequestSummaryTab({ } label="Labels"> {detail.labels.map((label) => { - const dot = labelDotColor(label.color); + const dot = pullRequestLabelColor(label.color); return ( ; } +export interface PullRequestAuthorFacet { + readonly actor: PullRequestActor; + readonly count: number; + readonly mergedCount: number; +} + +export interface PullRequestLabelFacet extends PullRequestLabel { + readonly count: number; +} + /** * The signed-in account per host. Keyed `" "` once a listing spans more than * one environment: two machines can both reach github.com signed in as different people, and a @@ -65,6 +77,59 @@ function normalize(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +export function pullRequestLabelColor(color: string | null): string | null { + const hex = color?.trim().replace(/^#/, "") ?? ""; + return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; +} + +export function collectPullRequestListFacets( + entries: ReadonlyArray, + state: PullRequestListState, +) { + const authors = new Map(); + const labels = new Map(); + const uniqueEntries = new Map(entries.map((entry) => [pullRequestEntryKey(entry), entry])); + for (const entry of uniqueEntries.values()) { + const inState = state === "all" || entry.state === state; + if (entry.author !== null) { + const key = normalize(entry.author.login); + if (key !== null) { + const held = authors.get(key); + authors.set(key, { + actor: held?.actor ?? entry.author, + count: (held?.count ?? 0) + Number(inState), + mergedCount: (held?.mergedCount ?? 0) + Number(entry.state === "merged"), + }); + } + } + if (!inState) continue; + for (const label of entry.labels) { + const key = normalize(label.name); + if (key === null) continue; + const held = labels.get(key); + labels.set(key, { + ...label, + name: held?.name ?? label.name, + color: held?.color ?? label.color, + count: (held?.count ?? 0) + 1, + }); + } + } + return { + authors: [...authors.values()] + .filter((author) => author.count > 0) + .toSorted( + (left, right) => + right.mergedCount - left.mergedCount || + right.count - left.count || + left.actor.login.localeCompare(right.actor.login), + ), + labels: [...labels.values()].toSorted( + (left, right) => right.count - left.count || left.name.localeCompare(right.name), + ), + }; +} + /** * The signed-in login for the host a row came from, or null where none was given. Shared by * authorship matching here and by `author:me` resolution wherever a row's own viewer is needed. @@ -433,13 +498,16 @@ export function mergePullRequestDiffStats( if (stats.length === 0) return previous; const next = new Map(previous); for (const stat of stats) { - next.set(diffStatKey(stat), { additions: stat.additions, deletions: stat.deletions }); + next.set(pullRequestDiffStatKey(stat), { + additions: stat.additions, + deletions: stat.deletions, + }); } return next; } /** A project id only names a project within its own environment, so the key carries both. */ -const diffStatKey = (row: { +export const pullRequestDiffStatKey = (row: { readonly environmentId: string; readonly projectId: string; readonly number: number; @@ -792,6 +860,6 @@ export function withDiffStat< statsByRow: ReadonlyMap, ): Entry { if (entry.additions !== 0 || entry.deletions !== 0) return entry; - const stat = statsByRow.get(diffStatKey(entry)); + const stat = statsByRow.get(pullRequestDiffStatKey(entry)); return stat === undefined ? entry : { ...entry, ...stat }; } diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 0f5d2edc9b6e5..fcd17dbb7b83e 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -13,7 +13,11 @@ import type { } from "@t3tools/contracts"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { + ArrowDownUpIcon, + CalendarArrowDownIcon, + CalendarArrowUpIcon, ChevronDownIcon, + ClockIcon, EyeIcon, MonitorIcon, ServerIcon, @@ -23,6 +27,8 @@ import { LayersIcon, PenLineIcon, LoaderIcon, + Maximize2Icon, + Minimize2Icon, RefreshCwIcon, SearchIcon, } from "lucide-react"; @@ -31,6 +37,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { filterPullRequestsByInvolvement, findScopedProject, + collectPullRequestListFacets, groupPullRequestsByInvolvement, matchesPullRequestFilters, matchesPullRequestQuery, @@ -38,6 +45,7 @@ import { narrowPullRequestsToFilters, mergePullRequestDiffStats, partitionPullRequestsWithPriority, + pullRequestDiffStatKey, pullRequestEntryKey, pullRequestEntryViewer, rankPullRequestMatches, @@ -58,6 +66,7 @@ import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequ import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; import { PullRequestFiltersMenu, + PullRequestFilterOptionIcon, PullRequestSearchInput, pullRequestHostLabel, pullRequestProjectKey, @@ -83,6 +92,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; +import { toSortableTimestamp } from "../lib/threadSort"; import { selectActiveRightPanelSurface, selectSelectedRightPanelSurface, @@ -136,8 +146,13 @@ export interface PullRequestsSearch { readonly draft?: "only" | "hide"; readonly review?: NonNullable; readonly checks?: NonNullable; + readonly author?: string; + readonly labels?: ReadonlyArray; + readonly sort?: PullRequestListSort; } +type PullRequestListSort = "updated" | "newest" | "oldest" | "largest" | "smallest"; + // The state filters wear the same glyphs the rows do, so the two read as one vocabulary. const INVOLVEMENT_TABS = [ { value: "all", label: "All", Icon: LayersIcon }, @@ -152,6 +167,14 @@ const STATE_TABS = [ { value: "merged", label: "Merged", Icon: GitMergeIcon }, ] as const satisfies ReadonlyArray>; +const SORT_OPTIONS = [ + { value: "updated", label: "Recently updated", Icon: ClockIcon }, + { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, + { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, + { value: "largest", label: "Largest shown", Icon: Maximize2Icon }, + { value: "smallest", label: "Smallest shown", Icon: Minimize2Icon }, +] as const satisfies ReadonlyArray>; + /** Long enough that a keystroke does not become a request, short enough to feel answered. */ const SEARCH_DEBOUNCE_MS = 250; /** What `scorePullRequestMatch` gives a row none of whose own fields carry the search text. */ @@ -181,6 +204,26 @@ const EMPTY_PREVIEW_SESSIONS = {}; const EMPTY_PREVIEW_DESKTOP_STATE = {}; const EMPTY_TERMINAL_LABELS = new Map(); const EMPTY_PENDING_SURFACES = new Set(); +const MAX_SEARCH_LABEL_CANDIDATES = 100; + +function pullRequestSearchLabels(raw: unknown): Partial> { + const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice( + 0, + MAX_SEARCH_LABEL_CANDIDATES, + ); + const labels: Array = []; + const seen = new Set(); + for (const rawValue of values) { + if (typeof rawValue !== "string") continue; + const value = rawValue.trim().slice(0, 200); + const key = value.toLowerCase(); + if (value.length === 0 || seen.has(key)) continue; + labels.push(value); + seen.add(key); + if (labels.length === 10) break; + } + return labels.length === 0 ? {} : { labels }; +} export const Route = createFileRoute("/_chat/pull-requests")({ validateSearch: (raw: Record): PullRequestsSearch => ({ @@ -188,6 +231,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", state: raw.state === "closed" || raw.state === "merged" || raw.state === "all" ? raw.state : "open", + ...(SORT_OPTIONS.some((option) => option.value === raw.sort) + ? { sort: raw.sort as PullRequestListSort } + : {}), ...(typeof raw.repository === "string" && raw.repository ? { repository: raw.repository.slice(0, 200) } : {}), @@ -216,12 +262,17 @@ export const Route = createFileRoute("/_chat/pull-requests")({ ? { review: raw.review } : {}), ...(raw.checks === "passing" || raw.checks === "failing" ? { checks: raw.checks } : {}), + ...(typeof raw.author === "string" && raw.author.trim() + ? { author: raw.author.trim().slice(0, 200) } + : {}), + ...pullRequestSearchLabels(raw.labels), }), component: PullRequestsRouteView, }); function PullRequestsRouteView() { const search = Route.useSearch(); + const sort = search.sort ?? "updated"; const navigate = useNavigate({ from: Route.fullPath }); const { environments } = useEnvironments(); // Every connected environment that has said it can list pull requests. Sorted, so the query @@ -416,6 +467,7 @@ function PullRequestsRouteView() { return { involvement: next.involvement ?? previous.involvement, state: next.state ?? previous.state, + ...(next.sort && next.sort !== "updated" ? { sort: next.sort } : {}), ...(next.repository ? { repository: next.repository } : {}), ...(next.number ? { number: next.number } : {}), ...(next.projectId ? { projectId: next.projectId } : {}), @@ -429,6 +481,8 @@ function PullRequestsRouteView() { ...(next.draft ? { draft: next.draft } : {}), ...(next.review ? { review: next.review } : {}), ...(next.checks ? { checks: next.checks } : {}), + ...(next.author ? { author: next.author } : {}), + ...(next.labels && next.labels.length > 0 ? { labels: next.labels } : {}), }; }, replace: true, @@ -458,6 +512,7 @@ function PullRequestsRouteView() { // it is sent. Until it lands, the rows already on screen are narrowed locally: the answer is // late but the page is not. const typedQuery = (search.q ?? "").trim(); + const [filtersOpen, setFiltersOpen] = useState(false); const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS); const querySettled = typedQuery === sentQuery; // What was typed, split into the qualifiers the hosts can act on and the words that are left. @@ -472,12 +527,14 @@ function PullRequestsRouteView() { ...(search.draft ? { draft: search.draft } : {}), ...(search.review ? { review: search.review } : {}), ...(search.checks ? { checks: search.checks } : {}), + ...(search.author ? { author: search.author } : {}), + ...(search.labels ? { labels: search.labels.map((label) => [label]) } : {}), }), - [search.checks, search.draft, search.review], + [search.author, search.checks, search.draft, search.labels, search.review], ); const menuFiltered = Object.keys(menuFilters).length > 0; // A typed qualifier wins over the menu's own answer for the same thing, since it is the more - // recent word on it; labels only ever come from the query, so there is nothing to overrule. + // recent word on it, including a typed author or label over its menu counterpart. const filters = useMemo( (): PullRequestListFilters => ({ ...menuFilters, ...sentParsed.filters }), [menuFilters, sentParsed.filters], @@ -548,7 +605,7 @@ function PullRequestsRouteView() { [environmentQueries], ); // Page size is view state, not a URL concern: a shared link should open the first page. - const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}`; + const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}:${search.author ?? ""}:${search.labels?.join("\u0000") ?? ""}`; const filterKey = `${scopeKey}:${sentQuery}`; // Where the next slice carries on from, per repository within each environment, as that // environment handed it back. Sending it is what makes a second page cost a second page rather @@ -660,6 +717,21 @@ function PullRequestsRouteView() { ], ); const baselineQuery = usePullRequestList(baselineTargets); + const facetTargets = useMemo(() => { + if (!filtersOpen) return NO_LIST_TARGETS; + return environmentQueries.map(({ environmentId, projectIds }) => ({ + environmentId, + input: { + state: "all", + involvement: search.involvement, + limit: PAGE_SIZE, + ...(scopedProjectId ? { projectId: scopedProjectId } : {}), + ...(projectIds ? { projectIds } : {}), + ...(search.host ? { host: search.host } : {}), + } satisfies PullRequestListInput, + })); + }, [environmentQueries, filtersOpen, scopedProjectId, search.host, search.involvement]); + const facetQuery = usePullRequestList(facetTargets); // The priority groups' own reads. The feed below is paginated by recency, so an older authored // or review-requested row can be missing from its first page; partitioned from these // server-filtered reads instead, the priority view is complete up front and a continuation can @@ -720,6 +792,7 @@ function PullRequestsRouteView() { } refreshList(); baselineQuery.refresh(); + facetQuery.refresh(); authoredQuery.refresh(); reviewingQuery.refresh(); statsQuery.refresh(); @@ -982,6 +1055,17 @@ function PullRequestsRouteView() { const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS; const listErrors = baselineQuery.data?.errors ?? listData?.errors ?? []; + const facets = useMemo( + () => + collectPullRequestListFacets( + [ + ...(facetQuery.data?.entries ?? []), + ...(baselineQuery.data?.entries ?? listData?.entries ?? []), + ], + search.state, + ), + [baselineQuery.data?.entries, facetQuery.data?.entries, listData?.entries, search.state], + ); /** The hosts that narrowed the listing themselves, so their answer is not narrowed again. */ const searchingHosts = useMemo( @@ -1170,6 +1254,40 @@ function PullRequestsRouteView() { if (stats === null) return; setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); }, [statsQuery.stats]); + const displayGroups = useMemo(() => { + const enriched = groups.map((group) => ({ + ...group, + entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)), + })); + if (sort === "updated") return enriched; + const entries = enriched.flatMap((group) => group.entries); + const hasSize = (entry: (typeof entries)[number]) => + entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)); + const timestamp = (entry: (typeof entries)[number]) => + toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; + return [ + { + key: "others" as const, + label: "", + entries: entries.toSorted((left, right) => { + if (sort === "newest" || sort === "oldest") { + const leftCreated = toSortableTimestamp(left.createdAt); + const rightCreated = toSortableTimestamp(right.createdAt); + const measured = Number(rightCreated !== null) - Number(leftCreated !== null); + const dated = (leftCreated ?? 0) - (rightCreated ?? 0); + return ( + measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) + ); + } + const measured = Number(hasSize(right)) - Number(hasSize(left)); + const sized = left.additions + left.deletions - (right.additions + right.deletions); + return ( + measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) + ); + }), + }, + ]; + }, [groups, sort, statsByRow]); const linkedSelection = useMemo( () => @@ -1347,6 +1465,7 @@ function PullRequestsRouteView() { onRefresh={() => void refreshFromHost()} query={typedQuery} filtered={ + menuFiltered || search.state !== "open" || search.involvement !== "all" || scopedProjectId !== undefined || @@ -1360,7 +1479,7 @@ function PullRequestsRouteView() { /> ) : (
- {groups.map((group) => ( + {displayGroups.map((group) => (
{group.label ? (

@@ -1370,10 +1489,7 @@ function PullRequestsRouteView() { {group.entries.map((entry) => ( 1 && @@ -1449,8 +1565,20 @@ function PullRequestsRouteView() { Icon: environment.displayUrl === null ? MonitorIcon : ServerIcon, })), ]; + const sortMenu = ( + } + triggerLabel="Sort" + outlined + value={sort} + options={SORT_OPTIONS} + onChange={(next) => updateSearch({ sort: next })} + /> + ); const filtersMenu = ( updateListScope({ state })} @@ -1459,8 +1587,16 @@ function PullRequestsRouteView() { onInvolvement={(involvement) => updateListScope({ involvement })} filters={menuFilters} onFilters={(next) => - updateListScope({ draft: next.draft, review: next.review, checks: next.checks }) + updateListScope({ + draft: next.draft, + review: next.review, + checks: next.checks, + author: next.author, + labels: next.labels?.flatMap((group) => group), + }) } + authorOptions={facets.authors} + labelOptions={facets.labels} host={search.host} hostOptions={hostMenuOptions} onHost={(host) => updateListScope({ host })} @@ -1493,6 +1629,7 @@ function PullRequestsRouteView() { onState: (state: PullRequestListState) => updateListScope({ state }), onHost: (host: string | undefined) => updateListScope({ host }), searchInput, + sortMenu, filtersMenu, rightPanelControl: // Footprint reserve while the panel is closed: the toggle itself stays @@ -1625,12 +1762,18 @@ function PullRequestsRouteView() { /** A compact stand-in for one pill group when the header is narrow. */ function CompactFilterMenu({ label, + triggerIcon, + triggerLabel, + outlined = false, value, options, onChange, className, }: { label: string; + triggerIcon?: ReactNode; + triggerLabel?: string; + outlined?: boolean; value: Value; options: ReadonlyArray>; onChange: (value: Value) => void; @@ -1641,14 +1784,28 @@ function CompactFilterMenu({ return ( : undefined} + className={ + outlined + ? className + : cn( + "inline-flex h-7 min-w-0 items-center gap-1 rounded-md px-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground", + className, + ) + } > - {current.label} - + {triggerLabel ? ( + <> + {triggerIcon} + {triggerLabel} + + ) : ( + <> + {current.label} + + + )} onChange(next as Value)}> @@ -1661,7 +1818,7 @@ function CompactFilterMenu({ className="data-disabled:pointer-events-auto" > - + {option.label} @@ -1769,6 +1926,7 @@ function PullRequestsColumn({ onState, onHost, searchInput, + sortMenu, filtersMenu, rightPanelControl, titlebarControls, @@ -1786,6 +1944,7 @@ function PullRequestsColumn({ onState: (state: PullRequestListState) => void; onHost: (host: string | undefined) => void; searchInput: ReactNode; + sortMenu: ReactNode; filtersMenu: ReactNode; rightPanelControl: ReactNode; titlebarControls: ReactNode; @@ -1934,6 +2093,7 @@ function PullRequestsColumn({
{searchInput} + {sortMenu} {filtersMenu} {!condensed ? ( diff --git a/docs/user/source-control.md b/docs/user/source-control.md index d848226c1baac..ac11e3a06f466 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -41,6 +41,9 @@ T3 Code works with the platforms your team already uses: - See if your current branch already has an open PR/MR - Open several reviews from the **Pull requests** page as tabs in the right panel +- Filter the list by author or labels, rank authors by merges in the loaded results, see label and + change-size context on each row, and sort the results currently shown by update time, creation + time, or change size - While working in a thread, open linked reviews in the same compact right-panel tabs without leaving the conversation - Open the review directly in your browser with one click From ff93aba61dc1f7839713580bc8ee08da2a395f9a Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 00:56:22 -0400 Subject: [PATCH 006/136] feat(web): search individual settings by detail (#8831) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../components/CommandPalette.logic.test.ts | 70 +++++ .../src/components/CommandPalette.logic.ts | 39 ++- apps/web/src/components/CommandPalette.tsx | 40 ++- .../settings/ConnectionsSettings.tsx | 14 +- ...ProviderSettingsPanel.environment.test.tsx | 38 +++ .../ProviderSettingsPanel.logic.test.ts | 22 ++ .../settings/ProviderSettingsPanel.logic.ts | 7 + .../settings/ProviderSettingsPanel.tsx | 52 +++- .../components/settings/SettingsPanels.tsx | 9 +- .../settings/SettingsSidebarNav.tsx | 19 +- .../settings/SourceControlSettings.tsx | 18 +- .../settings/SourceControlWritingSettings.tsx | 7 +- .../src/components/settings/ThemeSettings.tsx | 7 +- .../components/settings/settingsLayout.tsx | 12 + .../settings/settingsSearch.test.ts | 82 +++++- .../src/components/settings/settingsSearch.ts | 248 +++++++++++++++--- .../useAvailableSettingsSearchItems.ts | 44 ++++ apps/web/src/lib/utils.ts | 4 + docs/user/keybindings.md | 9 +- 19 files changed, 645 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/components/settings/useAvailableSettingsSearchItems.ts diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index d521c119303d2..65f1839400181 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -8,6 +8,7 @@ import { enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, + normalizeSearchText, reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -272,6 +273,75 @@ describe("buildThreadActionItems", () => { expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); }); + it("ranks an order-independent setting title match above a split context match", () => { + const settingsSearchItems = [ + { + kind: "action" as const, + value: "setting:context-match", + searchTerms: ["Pairing settings", "remote backend"], + title: "Context match", + icon: null, + run: async () => undefined, + }, + { + kind: "action" as const, + value: "setting:remote-pairing", + searchTerms: ["Remote pairing", "connections"], + title: "Remote pairing", + icon: null, + run: async () => undefined, + }, + ]; + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "pairing remote", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems, + threadSearchItems: [], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.value).toBe("settings-search"); + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "setting:remote-pairing", + "setting:context-match", + ]); + }); + + it("keeps accent-insensitive setting results", () => { + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "thè\u{1ab0}mes", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [ + { + kind: "action", + value: "setting:theme", + searchTerms: ["Themes", "Appearance"], + title: "Themes", + icon: null, + run: async () => undefined, + }, + ], + threadSearchItems: [], + }); + + expect(groups[0]?.items.map((item) => item.value)).toEqual(["setting:theme"]); + }); + + it("normalizes case independently of the host locale", () => { + const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt"); + try { + expect(normalizeSearchText("GIT")).toBe("git"); + expect(localeLowerCase).not.toHaveBeenCalled(); + } finally { + localeLowerCase.mockRestore(); + } + }); + it("keeps message excerpts searchable without replacing thread metadata", () => { const [item] = buildThreadActionItems({ threads: [makeThread({ branch: "feat/search" })], diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 688a8a8ea7916..a0af1be0450b2 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -9,9 +9,12 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; +import { normalizeSearchText } from "../lib/utils"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; +export { normalizeSearchText } from "../lib/utils"; + export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; @@ -138,10 +141,6 @@ export function enumerateCommandPaletteItems( export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; -export function normalizeSearchText(value: string): string { - return value.trim().toLowerCase().replace(/\s+/g, " "); -} - export function buildProjectActionItems(input: { projects: ReadonlyArray; valuePrefix: string; @@ -255,9 +254,16 @@ export function buildThreadActionItems, +): number { const normalizedField = normalizeSearchText(field); - if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) { + if ( + normalizedField.length === 0 || + !queryTokens.every((token) => normalizedField.includes(token)) + ) { return Number.NEGATIVE_INFINITY; } if (normalizedField === normalizedQuery) { @@ -266,12 +272,16 @@ function rankSearchFieldMatch(field: string, normalizedQuery: string): number { if (normalizedField.startsWith(normalizedQuery)) { return 2; } - return 1; + if (normalizedField.includes(normalizedQuery)) { + return 1; + } + return 0; } function rankCommandPaletteItemMatch( item: CommandPaletteActionItem | CommandPaletteSubmenuItem, normalizedQuery: string, + queryTokens: ReadonlyArray, ): number { const terms = item.searchTerms.filter((term) => term.length > 0); if (terms.length === 0) { @@ -279,7 +289,7 @@ function rankCommandPaletteItemMatch( } for (const [index, field] of terms.entries()) { - const fieldRank = rankSearchFieldMatch(field, normalizedQuery); + const fieldRank = rankSearchFieldMatch(field, normalizedQuery, queryTokens); if (fieldRank !== Number.NEGATIVE_INFINITY) { return 1_000 - index * 100 + fieldRank; } @@ -293,6 +303,7 @@ export function filterCommandPaletteGroups(input: { query: string; isInSubmenu: boolean; projectSearchItems: ReadonlyArray; + settingsSearchItems?: ReadonlyArray; threadSearchItems: ReadonlyArray; }): CommandPaletteGroup[] { const isActionsFilter = input.query.startsWith(">"); @@ -305,6 +316,7 @@ export function filterCommandPaletteGroups(input: { } return [...input.activeGroups]; } + const queryTokens = normalizedQuery.split(" "); let baseGroups = [...input.activeGroups]; if (isActionsFilter) { @@ -322,6 +334,13 @@ export function filterCommandPaletteGroups(input: { items: input.projectSearchItems, }); } + if (input.settingsSearchItems && input.settingsSearchItems.length > 0) { + searchableGroups.push({ + value: "settings-search", + label: "Settings", + items: input.settingsSearchItems, + }); + } if (input.threadSearchItems.length > 0) { searchableGroups.push({ value: "threads-search", @@ -334,14 +353,14 @@ export function filterCommandPaletteGroups(input: { return searchableGroups.flatMap((group) => { const items = Arr.filterMap(group.items, (item, index) => { const haystack = normalizeSearchText(item.searchTerms.join(" ")); - if (!haystack.includes(normalizedQuery)) { + if (!queryTokens.every((token) => haystack.includes(token))) { return Result.failVoid; } return Result.succeed({ item, index, - rank: rankCommandPaletteItemMatch(item, normalizedQuery), + rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens), }); }) .toSorted((left, right) => right.rank - left.rank || left.index - right.index) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c5ec3f0951672..52a36cf2ad569 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -32,7 +32,7 @@ import { type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { ArrowLeftIcon, @@ -104,6 +104,7 @@ import { } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems"; import { applyWslEnvironmentConfiguration, parseWslUncPath, @@ -140,6 +141,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; +import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; import { COMMAND_PALETTE_META_ICON_CLASS, CommandPaletteMetaDot, @@ -563,6 +565,7 @@ function OpenCommandPaletteDialog(props: { readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); @@ -585,6 +588,7 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const availableSettingsSearchItems = useAvailableSettingsSearchItems(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); @@ -1637,7 +1641,19 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:project-settings", - searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + searchTerms: [ + "project", + "settings", + "name", + "icon", + "scripts", + "model", + "workspace", + "grouping", + "checkout", + "remove", + "t3.json", + ], title: "Project settings", description: contextualProjectGroup.displayName, icon: , @@ -1651,6 +1667,25 @@ function OpenCommandPaletteDialog(props: { } const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); + const settingsSearchItems: CommandPaletteActionItem[] = searchSettings( + deferredQuery, + availableSettingsSearchItems, + ).map((item) => ({ + kind: "action", + value: `setting:${item.id}`, + searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])], + title: item.title, + description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`, + icon: , + run: async () => { + await navigate({ + to: item.to, + hash: item.targetId ?? item.id, + replace: pathname === item.to, + hashScrollIntoView: false, + }); + }, + })); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; const activeGroups = @@ -1668,6 +1703,7 @@ function OpenCommandPaletteDialog(props: { query: deferredQuery, isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, + settingsSearchItems, threadSearchItems: allThreadItems, }); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 6a65856b9b980..6ca31aa626f64 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1670,7 +1670,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b <> {window.desktopBridge ? ( ) : null} ( ( ( {canManageLocalBackend ? ( <> - + {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( ) : ( - + ({ updateSettings: vi.fn(), })); +const settingsSearchState = vi.hoisted(() => ({ + targetId: null as string | null, + effects: [] as Array<() => void>, +})); + vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); const { reactHookHarness } = await import("../../test/reactHookHarness"); return { ...actual, useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void) => settingsSearchState.effects.push(effect), useMemo: reactHookHarness.useMemo, useRef: reactHookHarness.useRef, useState: reactHookHarness.useState, }; }); +vi.mock("./settingsLayout", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSettingsSearchTargetId: () => settingsSearchState.targetId, + }; +}); + vi.mock("react/compiler-runtime", async () => { const { reactHookHarness } = await import("../../test/reactHookHarness"); return { c: reactHookHarness.useMemoCache }; @@ -131,6 +145,17 @@ function isAddProviderButton(element: ReactElement>): bo return element.props["aria-label"] === "Add provider"; } +function findAdvancedPanel(panel: ReactElement>) { + return visitElements( + panel, + (element) => element.props.className === "mt-1" && typeof element.props.open === "boolean", + ); +} + +function flushEffects(): void { + for (const effect of settingsSearchState.effects.splice(0)) effect(); +} + async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -144,6 +169,8 @@ describe("EnvironmentProviderSettings routing", () => { settingsState.readEnvironmentIds = []; settingsState.updateEnvironmentIds = []; settingsState.updateSettings.mockReset(); + settingsSearchState.targetId = null; + settingsSearchState.effects = []; commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); }); @@ -235,6 +262,17 @@ describe("EnvironmentProviderSettings routing", () => { expect(visitElements(panel, isAddProviderButton)).not.toBeNull(); }); + it("opens Advanced when search targets the provider health interval", () => { + settingsSearchState.targetId = "provider-health-check-interval"; + let panel = renderPanel(); + + expect(findAdvancedPanel(panel)?.props.open).toBe(false); + flushEffects(); + + panel = renderPanel(); + expect(findAdvancedPanel(panel)?.props.open).toBe(true); + }); + it("deletes and resets provider configuration without erasing shared preferences", () => { settingsState.value = { ...DEFAULT_UNIFIED_SETTINGS, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index bf558f5a4d664..c04db646a47ed 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, + isProviderSettingsEnvironmentAvailable, resolvePrimaryOperateAccess, resolveRemoteOperateAccess, resolveSelectedProviderEnvironmentId, @@ -20,6 +21,27 @@ const environments = [ ] as const; describe("provider environment selection", () => { + it("requires a connected environment with server config for searchable provider settings", () => { + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "connected", + hasServerConfig: true, + }), + ).toBe(true); + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "reconnecting", + hasServerConfig: true, + }), + ).toBe(false); + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "connected", + hasServerConfig: false, + }), + ).toBe(false); + }); + it("sorts the primary environment first and the rest by label", () => { expect( buildProviderEnvironmentOptions(environments, primaryId).map( diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 1c7dac391f6aa..b415b5f69b07d 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -10,6 +10,13 @@ export interface ProviderEnvironmentOptionLike { readonly label: string; } +export function isProviderSettingsEnvironmentAvailable(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly hasServerConfig: boolean; +}): boolean { + return input.connectionPhase === "connected" && input.hasServerConfig; +} + export function buildProviderEnvironmentOptions( environments: ReadonlyArray, primaryEnvironmentId: EnvironmentId | null, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 3fb193fe1072f..bafff6f48b8b1 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -33,7 +33,7 @@ import { RefreshCwIcon, TerminalIcon, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { isElectron } from "../../env"; @@ -93,10 +93,12 @@ import { SettingsRow, SettingsSection, useRelativeTimeTick, + useSettingsSearchTargetId, } from "./settingsLayout"; import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, + isProviderSettingsEnvironmentAvailable, type ProviderEnvironmentAccess, type ProviderOperateAccess, resolvePrimaryOperateAccess, @@ -189,7 +191,7 @@ function EnvironmentUnavailableRow({ // No spinner: this state can persist indefinitely for a wedged device, and a // continuously repainting animation would run the whole time. return ( - + {deviceTabs} @@ -197,8 +199,17 @@ function EnvironmentUnavailableRow({ } export function ProviderSettingsPanel() { + return ( + + + + ); +} + +function ProviderSettingsPanelContent() { const { environments, isReady } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const searchTargetId = useSettingsSearchTargetId(); const options = useMemo( () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), [environments, primaryEnvironmentId], @@ -216,6 +227,27 @@ export function ProviderSettingsPanel() { ); const selectedEnvironment = options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; + const selectedEnvironmentCanRenderSettings = + selectedEnvironment !== null && + isProviderSettingsEnvironmentAvailable({ + connectionPhase: selectedEnvironment.connection.phase, + hasServerConfig: selectedEnvironment.serverConfig !== null, + }); + const searchableEnvironmentId = options.find((environment) => + isProviderSettingsEnvironmentAvailable({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + }), + )?.environmentId; + useEffect(() => { + if ( + searchTargetId === searchableSetting("provider-health-check-interval").id && + !selectedEnvironmentCanRenderSettings && + searchableEnvironmentId !== undefined + ) { + setSelectedEnvironmentId(searchableEnvironmentId); + } + }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]); const onlyPrimaryDevice = options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; const deviceTabs = @@ -266,9 +298,9 @@ export function ProviderSettingsPanel() { ) : null; return ( - + <> {options.length === 0 ? ( - + ) : null} - + ); } @@ -429,12 +461,19 @@ export function EnvironmentProviderSettings({ const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); const [selectedInstanceId, setSelectedInstanceId] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); + const searchTargetId = useSettingsSearchTargetId(); const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< ReadonlySet >(() => new Set()); const refreshingRef = useRef(false); const updatingDriversRef = useRef>(new Set()); + useEffect(() => { + if (searchTargetId === searchableSetting("provider-health-check-interval").id) { + setAdvancedOpen(true); + } + }, [searchTargetId]); + const providerUpdateCandidates = useMemo( () => collectProviderUpdateCandidates(serverProviders), [serverProviders], @@ -915,9 +954,10 @@ export function EnvironmentProviderSettings({ className={readOnly ? "opacity-50 select-none" : undefined} > - Health check interval + {searchableSetting("provider-health-check-interval").title} This interval is configured here, then the shared Background activity policy decides whether provider probes may run when the timer fires. Custom diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index dbb2d96daa0f2..b150533b00449 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -790,7 +790,9 @@ function BackgroundActivityAdvancedDialog({
-
Git fetch interval
+
+ {searchableSetting("git-fetch-interval").title} +

Refresh remote branch status in the background.

@@ -2009,7 +2011,7 @@ export function GeneralSettingsPanel() { /> {settings.sidebarAutoSettleAfterDays !== null ? ( - Background activity + {searchableSetting("background-activity").title} This shared policy gates background work such as Git refreshes and provider health probes after their individual intervals elapse. diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index d336bde5ee163..daf1724a38d96 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -7,8 +7,6 @@ import { type ComponentType, type KeyboardEvent, } from "react"; -import { useEnvironmentQuery } from "~/state/query"; -import { desktopWslStateAtom } from "~/state/desktopWslState"; import { ArchiveIcon, BlocksIcon, @@ -23,8 +21,6 @@ import { } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { isElectron } from "~/env"; -import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd } from "../ui/kbd"; @@ -42,11 +38,11 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, - SETTINGS_SEARCH_ITEMS, SETTINGS_SECTION_LABELS, type SettingsPath, type SettingsSearchItem, } from "./settingsSearch"; +import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; const SETTINGS_SECTION_ICONS: Readonly< Record> @@ -83,18 +79,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); - const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); - const searchableItems = useMemo(() => { - const wslState = desktopWsl.data; - const rowRenders = isWslSettingsRowVisible({ - state: wslState, - error: desktopWsl.error, - }); - if (rowRenders) { - return SETTINGS_SEARCH_ITEMS; - } - return SETTINGS_SEARCH_ITEMS.filter((item) => item.id !== "wsl-backend"); - }, [desktopWsl.data, desktopWsl.error]); + const searchableItems = useAvailableSettingsSearchItems(); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index a43c116467ede..559a90f4bf5a7 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,7 +1,7 @@ import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import type { BackgroundActivitySettings, SourceControlProviderKind, @@ -59,7 +59,9 @@ import { PolicyTooltip, SettingResetButton, SettingsPageContainer, + SettingsSearchTarget, SettingsSection, + useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -267,6 +269,13 @@ function DiscoveryItemRow({ const authAccount = auth ? optionLabel(auth.account) : null; const [isExpanded, setIsExpanded] = useState(false); const hasDetails = children !== undefined; + const searchTargetId = useSettingsSearchTargetId(); + + useEffect(() => { + if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) { + setIsExpanded(true); + } + }, [item.kind, searchTargetId]); return (
+
- Fetch interval + {setting.title} This interval is configured for Git only. The shared Background activity policy still decides whether Git refreshes may run when the timer fires. Custom intervals appear as @@ -407,7 +417,7 @@ function GitFetchIntervalSettings() { seconds
-
+
); } diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index d7c094af372bf..7f3660c52b21b 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -21,6 +21,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; const MODE_OPTIONS: Record = { @@ -73,7 +74,7 @@ export function SourceControlWritingSettingsSection() { return ( diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 8f5ee2bba00d9..b75d5f40f4394 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -41,6 +41,7 @@ import { Button } from "../ui/button"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { ThemeImportDialog } from "./ThemeImportDialog"; +import { searchableSetting } from "./settingsSearch"; import { useThemeEditorStore } from "./themeEditorStore"; import { STANDARD_THEME_CARDS, @@ -892,11 +893,13 @@ export function ThemeLibrary({ Choose how T3 Code looks. Use a built-in theme or make your own.

- Color scheme + {searchableSetting("color-scheme").title}

{renderModeTiles()}
-

Themes

+

+ {searchableSetting("theme").title} +

) : null} diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts new file mode 100644 index 0000000000000..0289496c19ba2 --- /dev/null +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadId } from "@t3tools/contracts"; + +import { resolveViewedImageAsset, workEntryViewedImagePath } from "./presentation.js"; + +describe("workEntryViewedImagePath", () => { + const entry = { label: "Read", tone: "tool" } as const; + + it("returns a single image path from supported read entries", () => { + expect( + workEntryViewedImagePath({ ...entry, requestKind: "file-read", detail: " assets/a.png " }), + ).toBe("assets/a.png"); + expect( + workEntryViewedImagePath({ + ...entry, + itemType: "dynamic_tool_call", + toolTitle: "Read file", + detail: "C:\\workspace\\a.webp", + }), + ).toBe("C:\\workspace\\a.webp"); + }); + + it("rejects non-image, multi-line, and non-read details", () => { + expect( + workEntryViewedImagePath({ ...entry, itemType: "image_view", detail: "a.txt" }), + ).toBeNull(); + expect( + workEntryViewedImagePath({ ...entry, itemType: "image_view", detail: "a.png\nb.png" }), + ).toBeNull(); + expect(workEntryViewedImagePath({ ...entry, detail: "a.png" })).toBeNull(); + }); +}); + +describe("resolveViewedImageAsset", () => { + const threadId = ThreadId.make("thread-1"); + + it("loads t3 attachment paths as attachments", () => { + const attachmentId = + "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222"; + expect( + resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, { + threadId, + workspaceRoot: "/workspace", + }), + ).toEqual({ + resource: { _tag: "attachment", attachmentId }, + alt: `${attachmentId}.png`, + srcFragment: "", + }); + }); + + it("normalizes workspace image sources", () => { + expect( + resolveViewedImageAsset("screens/logo.svg?v=2#mark", { + threadId, + workspaceRoot: "/workspace", + }), + ).toEqual({ + resource: { + _tag: "workspace-file", + threadId, + path: "/workspace/screens/logo.svg", + }, + alt: "logo.svg", + srcFragment: "#mark", + }); + expect(resolveViewedImageAsset("https://example.com/logo.png", { threadId })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 4834c037bb4cc..416395916e1a6 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -1,4 +1,12 @@ -import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts"; +import { + isToolLifecycleItemType, + type AssetResource, + type ThreadId, + type ToolLifecycleItemType, +} from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; + +import { classifyMarkdownImageSource, markdownImageSourceFragment } from "../markdownImages.js"; export function isWorktreeSetupActivity(kind: string): boolean { return kind === "setup-script.requested" || kind === "setup-script.started"; @@ -9,6 +17,7 @@ export interface WorkLogPresentationEntry { readonly toolTitle?: string; readonly tone: "thinking" | "tool" | "info" | "error"; readonly command?: string; + readonly detail?: string; readonly changedFiles?: ReadonlyArray; readonly itemType?: ToolLifecycleItemType; readonly requestKind?: string; @@ -57,7 +66,8 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio if ( entry.requestKind === "file-read" || entry.itemType === "image_view" || - (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File") + (entry.itemType === "dynamic_tool_call" && + entry.toolTitle?.trim().toLowerCase() === "read file") ) { return "read"; } @@ -76,6 +86,53 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio return workLogEntryIsToolLike(entry) ? "other" : "update"; } +export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null { + const detail = entry.detail?.trim(); + return toolGroupAction(entry) === "read" && + detail !== undefined && + !/[\r\n]/.test(detail) && + isWorkspaceImagePreviewPath(detail) + ? detail + : null; +} + +export interface ViewedImageAsset { + readonly resource: Extract; + readonly alt: string; + readonly srcFragment: string; +} + +const ABSOLUTE_IMAGE_SOURCE_PATTERN = /^(?:file:|[\\/]|[a-z]:[\\/])/i; +const T3_ATTACHMENT_IMAGE_PATH_PATTERN = + /(?:^|[\\/])(?:dev|userdata)[\\/]attachments[\\/]([a-z0-9_-]{1,128})\.[a-z0-9]{1,10}$/i; + +export function resolveViewedImageAsset( + source: string, + input: { + readonly threadId: ThreadId; + readonly workspaceRoot?: string | null | undefined; + }, +): ViewedImageAsset | null { + const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? "."); + if (imageSource._tag !== "WorkspaceFile") return null; + + const path = + input.workspaceRoot == null && imageSource.path.startsWith("./") + ? imageSource.path.slice(2) + : imageSource.path; + const attachmentId = ABSOLUTE_IMAGE_SOURCE_PATTERN.test(source) + ? (T3_ATTACHMENT_IMAGE_PATH_PATTERN.exec(path)?.[1] ?? null) + : null; + + return { + resource: attachmentId + ? { _tag: "attachment", attachmentId } + : { _tag: "workspace-file", threadId: input.threadId, path }, + alt: path.split(/[\\/]/).at(-1) ?? "image", + srcFragment: markdownImageSourceFragment(source), + }; +} + function toolGroupActionCount( action: ToolGroupAction, entries: ReadonlyArray, From 0947c30e6946b2ad6d6cd518fd44292e75e834e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 23:57:03 -0700 Subject: [PATCH 008/136] fix(client): use package import for markdown image helpers (#9010) --- packages/client-runtime/src/work-log/presentation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 416395916e1a6..902745fdf958f 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -4,10 +4,12 @@ import { type ThreadId, type ToolLifecycleItemType, } from "@t3tools/contracts"; +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; -import { classifyMarkdownImageSource, markdownImageSourceFragment } from "../markdownImages.js"; - export function isWorktreeSetupActivity(kind: string): boolean { return kind === "setup-script.requested" || kind === "setup-script.started"; } From 73776d4e52087331245e82ebf3c053ae0991935d Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:44:20 +0000 Subject: [PATCH 009/136] test: remove static presentation snapshots (#9008) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- apps/mobile/src/lib/typography.test.ts | 20 -------------------- apps/web/src/components/ui/menu.test.tsx | 23 ----------------------- 2 files changed, 43 deletions(-) delete mode 100644 apps/mobile/src/lib/typography.test.ts delete mode 100644 apps/web/src/components/ui/menu.test.tsx diff --git a/apps/mobile/src/lib/typography.test.ts b/apps/mobile/src/lib/typography.test.ts deleted file mode 100644 index 5b62e9bd31279..0000000000000 --- a/apps/mobile/src/lib/typography.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; - -describe("mobile typography", () => { - it("uses the intentional mobile font scale anchored at a 16pt body", () => { - expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([ - 11, 12, 13, 14, 16, 18, 21, 26, 30, - ]); - expect(MOBILE_TYPOGRAPHY.body).toEqual({ fontSize: 16, lineHeight: 23 }); - }); - - it("uses caption-sized code with a compact readable row height", () => { - expect(MOBILE_CODE_SURFACE).toMatchObject({ - fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, - lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, - rowHeight: 22, - }); - }); -}); diff --git a/apps/web/src/components/ui/menu.test.tsx b/apps/web/src/components/ui/menu.test.tsx deleted file mode 100644 index 079d2a1794b81..0000000000000 --- a/apps/web/src/components/ui/menu.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { Menu, MenuRadioGroup, MenuRadioItem } from "./menu"; - -describe("menu radio item geometry", () => { - it("keeps radio-item icons on the same text grid as menu items", () => { - const html = renderToStaticMarkup( - - - - - - Merge - - - - , - ); - - expect(html).toContain("-mx-0.5"); - }); -}); From a9ffb8279614df6ae2f1f4b7f09a0dc42edef797 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 01:25:24 -0700 Subject: [PATCH 010/136] perf(server): bound snapshot activity payload memory (#9000) --- .../ActivityPayloadProjection.test.ts | 22 ++ .../ActivityPayloadProjection.ts | 31 +- .../Layers/ProjectionSnapshotQuery.test.ts | 157 ++++++++- .../Layers/ProjectionSnapshotQuery.ts | 329 ++++++++++++++---- .../Services/ProjectionSnapshotQuery.ts | 4 + 5 files changed, 449 insertions(+), 94 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 2cdfef19fd186..18732fa4ea377 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -64,6 +64,28 @@ describe("projectActivityPayload", () => { expect(JSON.stringify(projected.payload).length).toBeLessThan(500); }); + it("keeps preview normalization and fence-only fallback while scanning lines", () => { + const preview = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { rawOutput: `\`\`\`\n actual\tresult \n${"x".repeat(5000)}` }, + }), + ); + const fences = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { rawOutput: "```\r\n \t \n```\n" }, + }), + ); + + expect((preview.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({ + content: "actual result", + }); + expect((fences.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({ + content: "2 lines", + }); + }); + it("keeps bounded Claude and ACP command output summaries", () => { const claude = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 32f249c251d5b..7d2ba79ce4ad5 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -144,22 +144,29 @@ function projectCommandValue(data: Record): unknown { } function summarizeToolTextOutput(value: string): string | null { - const lines: string[] = []; - for (const rawLine of value.split(/\r?\n/u)) { - const line = rawLine.replace(/\s+/g, " ").trim(); + let meaningfulLineCount = 0; + let offset = 0; + + while (offset <= value.length) { + const newlineIndex = value.indexOf("\n", offset); + const lineEnd = newlineIndex === -1 ? value.length : newlineIndex; + const line = value.slice(offset, lineEnd).replace(/\s+/g, " ").trim(); if (line.length > 0) { - lines.push(line); + meaningfulLineCount += 1; + if (line !== "```") { + const summary = line.length <= 84 ? line : `${line.slice(0, 83).trimEnd()}…`; + // V8 can retain the full tool output behind a short sliced string. + // Join a tiny character array so the returned preview owns its bytes. + return Array.from(summary).join(""); + } + } + if (newlineIndex === -1) { + break; } + offset = newlineIndex + 1; } - const firstLine = lines.find((line) => line !== "```"); - if (firstLine) { - return firstLine.length <= 84 ? firstLine : `${firstLine.slice(0, 83).trimEnd()}…`; - } - if (lines.length > 1) { - return `${lines.length.toLocaleString()} lines`; - } - return null; + return meaningfulLineCount > 1 ? `${meaningfulLineCount.toLocaleString()} lines` : null; } /** diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 0a375c7629148..fd8b601b13eaf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -21,6 +21,7 @@ import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; +import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -2389,9 +2390,84 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = 'thread-w', 'turn-5', 'tool', - 'tool.completed', + CASE + WHEN sequence = 2 THEN 'tool.updated' + WHEN sequence IN (3, 70) THEN 'context-window.updated' + ELSE 'tool.completed' + END, 'ran tool', - printf('{"sequence":%d}', sequence), + CASE + WHEN sequence IN (2, 80) THEN json_object( + 'itemType', 'command_execution', + 'toolCallId', 'cross-batch-call', + 'title', CASE WHEN sequence = 80 THEN 'Build completed' ELSE 'Build' END, + 'status', 'completed', + 'data', json_object( + 'toolCallId', 'cross-batch-call', + 'item', json_object( + 'command', 'vp test run', + 'aggregatedOutput', printf( + 'command output%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'x') + ) + ), + 'rawOutput', printf( + 'raw output%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'y') + ), + 'files', json_array(json_object('path', 'apps/server/src/snapshot.ts')) + ) + ) + WHEN sequence = 10 THEN json_object( + 'itemType', 'mcp_tool_call', + 'status', 'completed', + 'data', json_object( + 'item', json_object( + 'type', 'mcpToolCall', + 'id', 'mcp-item-10', + 'tool', 'fetch_pr', + 'server', 'github', + 'status', 'completed', + 'arguments', json_object('pr', 42), + 'result', json_object( + 'content', json_array(json_object( + 'type', 'text', + 'text', printf( + 'PR body line one%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'z') + ) + )) + ), + '_meta', json_object('raw', replace(hex(zeroblob(8192)), '00', 'q')) + ) + ) + ) + WHEN sequence = 11 THEN json_object( + 'itemType', 'command_execution', + 'status', 'completed', + 'data', json_object( + 'item', json_object( + 'status', 'failed', + 'command', 'vp test run', + 'aggregatedOutput', printf( + 'failed command%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'w') + ) + ), + 'rawOutput', json_object('stdout', 'failed output'), + 'files', json_array(json_object('path', 'apps/server/src/failed.ts')) + ) + ) + WHEN sequence IN (3, 70) THEN json_object( + 'usedTokens', sequence * 100, + 'modelContextWindow', 100000 + ) + ELSE json_object('sequence', sequence) + END, sequence, '2026-03-01T00:04:00.000Z' FROM activity_rows @@ -2469,12 +2545,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW); assert.equal(detailWithPinnedRequests._tag, "Some"); if (detailWithPinnedRequests._tag === "Some") { - const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id); + const ids = new Set( + detailWithPinnedRequests.value.activities.map((activity) => activity.id), + ); assert.equal(detailWithPinnedRequests.value.activities.length, 503); - assert.equal(ids.includes(asEventId("approval-old")), true); - assert.equal(ids.includes(asEventId("user-input-old")), true); - assert.equal(ids.includes(asEventId("user-input-closed")), false); - assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + assert.equal(ids.has(asEventId("approval-old")), true); + assert.equal(ids.has(asEventId("user-input-old")), true); + assert.equal(ids.has(asEventId("user-input-closed")), false); + assert.equal(ids.has(asEventId("user-input-tied-z-request")), true); } const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { @@ -2482,12 +2560,67 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }); assert.equal(windowWithPinnedRequests._tag, "Some"); if (windowWithPinnedRequests._tag === "Some") { - const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id); + const ids = new Set( + windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id), + ); assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503); - assert.equal(ids.includes(asEventId("approval-old")), true); - assert.equal(ids.includes(asEventId("user-input-old")), true); - assert.equal(ids.includes(asEventId("user-input-closed")), false); - assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + assert.equal(ids.has(asEventId("approval-old")), true); + assert.equal(ids.has(asEventId("user-input-old")), true); + assert.equal(ids.has(asEventId("user-input-closed")), false); + assert.equal(ids.has(asEventId("user-input-tied-z-request")), true); + } + + const fullSnapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW); + assert.equal(fullSnapshot._tag, "Some"); + if ( + detailWithPinnedRequests._tag === "Some" && + fullSnapshot._tag === "Some" && + windowWithPinnedRequests._tag === "Some" + ) { + const projectedFullSnapshot = projectThreadDetailSnapshot(fullSnapshot.value); + const projectedRawBaseline = projectThreadDetailSnapshot({ + snapshotSequence: fullSnapshot.value.snapshotSequence, + thread: detailWithPinnedRequests.value, + }); + assert.deepStrictEqual(projectedFullSnapshot, projectedRawBaseline); + + const rawActivitiesById = new Map( + detailWithPinnedRequests.value.activities.map((activity) => [activity.id, activity]), + ); + const projectedWindowSnapshot = projectThreadDetailSnapshot(windowWithPinnedRequests.value); + const projectedWindowBaseline = projectThreadDetailSnapshot({ + ...windowWithPinnedRequests.value, + thread: { + ...windowWithPinnedRequests.value.thread, + activities: windowWithPinnedRequests.value.thread.activities.map( + (activity) => rawActivitiesById.get(activity.id) ?? activity, + ), + }, + }); + assert.deepStrictEqual(projectedWindowSnapshot, projectedWindowBaseline); + + const projectedIds = new Set( + projectedFullSnapshot.thread.activities.map((activity) => activity.id), + ); + assert.equal(projectedIds.has(asEventId("activity-0002")), false); + assert.equal(projectedIds.has(asEventId("activity-0003")), false); + assert.equal(projectedIds.has(asEventId("activity-0070")), true); + + const failedCommand = projectedFullSnapshot.thread.activities.find( + (activity) => activity.id === asEventId("activity-0011"), + ); + assert.deepStrictEqual(failedCommand?.payload, { + itemType: "command_execution", + status: "failed", + data: { + item: { + command: "vp test run", + aggregatedOutput: "failed command", + }, + files: [{ path: "apps/server/src/failed.ts" }], + rawOutput: { content: "failed output" }, + }, + }); } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index d3270b0ce5215..ea808f18d3a7a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -57,6 +57,7 @@ import { decodeThreadDetailPageCursor, encodeThreadDetailPageCursor, } from "../threadDetailCursor.ts"; +import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { @@ -75,6 +76,9 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); // activity window. Applying the limit in SQL avoids decoding an unbounded // payload_json set before the projector can enforce that invariant. const THREAD_DETAIL_ACTIVITY_LIMIT = 500; +// Snapshot payloads are decoded and projected in small sequential batches so +// one client read does not retain the raw payloads for the full activity window. +const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -100,6 +104,9 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( sequence: Schema.NullOr(NonNegativeInt), }), ); +const ProjectionThreadActivityIdRowSchema = Schema.Struct({ + activityId: ProjectionThreadActivity.fields.activityId, +}); const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( Struct.assign({ @@ -146,6 +153,9 @@ const ThreadActivityKindsLookupInput = Schema.Struct({ threadId: ThreadId, activityKinds: Schema.Array(Schema.String), }); +const ThreadActivityIdsLookupInput = Schema.Struct({ + activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId), +}); // Windowed reads order turns by the stable keyset (anchor, turn key), where // anchor is requested_at and turn key is // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the @@ -349,6 +359,21 @@ function mapProposedPlanRow( }; } +function mapThreadActivityRow( + row: Schema.Schema.Type, +): OrchestrationThreadActivity { + return { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -1060,6 +1085,44 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityIdsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId }) => + sql` + SELECT activity_id AS "activityId" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + `, + }); + + const listThreadActivityRowsByIds = SqlSchema.findAll({ + Request: ThreadActivityIdsLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ activityIds }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + -- The selectors already scoped these globally unique ids to the + -- thread inside this transaction. Keep this as a primary-key lookup. + WHERE ${sql.in("activity_id", activityIds)} + `, + }); + const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ Request: ThreadActivityKindsLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1310,15 +1373,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); - // Blocking request payloads must remain available even if they predate the - // recent activity window. Each CTE returns at most one unresolved row per - // request, so the merge below stays bounded by actionable work. - const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ - Request: ThreadIdLookupInput, - Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId }) => - sql` - WITH pending_approval_requests AS ( + const pinnedThreadActivityIdsCte = (threadId: string) => sql` +pending_approval_requests AS ( SELECT request_id, thread_id FROM projection_pending_approvals WHERE thread_id = ${threadId} @@ -1382,6 +1438,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE request_order = 1 AND kind = 'user-input.requested' ) + `; + + // Blocking request payloads must remain available even if they predate the + // recent activity window. Each CTE returns at most one unresolved row per + // request, so the merge below stays bounded by actionable work. + const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + WITH ${pinnedThreadActivityIdsCte(threadId)} SELECT activity.activity_id AS "activityId", activity.thread_id AS "threadId", @@ -1399,6 +1466,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listPinnedThreadActivityIdsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId }) => + sql` + WITH ${pinnedThreadActivityIdsCte(threadId)} + SELECT activity_id AS "activityId" + FROM pinned_activity_ids + `, + }); + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ Request: ThreadTurnRangeLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1466,6 +1544,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityIdsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT activity_id AS "activityId" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + `, + }); + const getFullThreadDiffContextRow = SqlSchema.findOneOption({ Request: FullThreadDiffContextLookupInput, Result: ProjectionFullThreadDiffContextRowSchema, @@ -2571,49 +2691,136 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly beforeTurnKey: string; } - const getThreadDetailByIdBounded = ( - threadId: ThreadId, - bounds: ThreadDetailBounds | undefined, - query: ProjectionThreadDetailQuery | undefined = undefined, - ) => - Effect.gen(function* () { - const activityRowsEffect = ( - query?.activityKinds === undefined - ? bounds === undefined - ? listThreadActivityRowsByThread({ threadId }) - : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) - : query.activityKinds.length === 0 - ? Effect.succeed([]) - : listThreadActivityRowsByThreadAndKinds({ - threadId, - activityKinds: query.activityKinds, - }) + type ThreadDetailActivityRead = + | { + readonly mode: "raw"; + readonly query?: ProjectionThreadDetailQuery; + } + | { + readonly mode: "client"; + }; + + const listProjectedThreadActivities = Effect.fn( + "ProjectionSnapshotQuery.listProjectedThreadActivities", + )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) { + const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([ + (bounds === undefined + ? listThreadActivityIdsByThread({ threadId }) + : listThreadActivityIdsByThreadWindow({ threadId, ...bounds }) ).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows", ), ), + ), + listPinnedThreadActivityIdsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:decodeRows", + ), + ), + ), + ]); + const activityIds = [ + ...new Set([...activityIdRows, ...pinnedActivityIdRows].map(({ activityId }) => activityId)), + ]; + const activities: OrchestrationThreadActivity[] = []; + + for ( + let offset = 0; + offset < activityIds.length; + offset += THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE + ) { + const batchIds = activityIds.slice( + offset, + offset + THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE, ); - const pinnedActivityRowsEffect = - query?.activityKinds === undefined - ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + const batchRows = yield* listThreadActivityRowsByIds({ activityIds: batchIds }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows", + ), + ), + ); + for (const row of batchRows) { + activities.push(projectActivityPayload(mapThreadActivityRow(row))); + } + } + + return activities.toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id), + ); + }); + + const getThreadDetailByIdBounded = ( + threadId: ThreadId, + bounds: ThreadDetailBounds | undefined, + activityRead: ThreadDetailActivityRead = { mode: "raw" }, + ) => + Effect.gen(function* () { + const activitiesEffect = + activityRead.mode === "client" + ? listProjectedThreadActivities(threadId, bounds) + : Effect.all([ + (activityRead.query?.activityKinds === undefined + ? bounds === undefined + ? listThreadActivityRowsByThread({ threadId }) + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) + : activityRead.query.activityKinds.length === 0 + ? Effect.succeed([]) + : listThreadActivityRowsByThreadAndKinds({ + threadId, + activityKinds: activityRead.query.activityKinds, + }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), ), ), - ) - : Effect.succeed([]); + activityRead.query?.activityKinds === undefined + ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ) + : Effect.succeed([]), + ]).pipe( + Effect.map(([activityRows, pinnedActivityRows]) => + [ + ...new Map( + [...activityRows, ...pinnedActivityRows].map( + (row) => [row.activityId, row] as const, + ), + ).values(), + ] + .toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ) + .map(mapThreadActivityRow), + ), + ); const [ threadRow, messageRows, proposedPlanRows, - activityRows, - pinnedActivityRows, + activities, checkpointRows, latestTurnRow, sessionRow, @@ -2645,8 +2852,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - activityRowsEffect, - pinnedActivityRowsEffect, + activitiesEffect, listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2677,17 +2883,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } - const selectedActivityRows = [ - ...new Map( - [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const), - ).values(), - ].toSorted( - (left, right) => - (left.sequence ?? -1) - (right.sequence ?? -1) || - left.createdAt.localeCompare(right.createdAt) || - left.activityId.localeCompare(right.activityId), - ); - const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2729,21 +2924,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: selectedActivityRows.map((row) => { - const activity = { - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - createdAt: row.createdAt, - }; - if (row.sequence !== null) { - return Object.assign(activity, { sequence: row.sequence }); - } - return activity; - }), + activities, checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, @@ -2768,7 +2949,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( threadId, query, - ) => getThreadDetailByIdBounded(threadId, undefined, query); + ) => + getThreadDetailByIdBounded(threadId, undefined, { + mode: "raw", + ...(query === undefined ? {} : { query }), + }); // Bounds pathological fan-out: one user turn that spawned hundreds of // subagent turns still pages in bounded chunks, at the cost of splitting the @@ -2792,7 +2977,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { .withTransaction( Effect.gen(function* () { if (window?.turnLimit === undefined) { - const thread = yield* getThreadDetailById(threadId); + const thread = yield* getThreadDetailByIdBounded(threadId, undefined, { + mode: "client", + }); if (Option.isNone(thread)) { return Option.none(); } @@ -2845,7 +3032,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } : undefined; - const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); + const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, { + mode: "client", + }); if (Option.isNone(thread)) { return Option.none(); } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index f783d4d07bba9..9428e84747cd0 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -191,6 +191,10 @@ export interface ProjectionSnapshotQueryShape { * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). * Without a window the full thread is returned with no `page` field — * pagination is strictly opt-in. + * + * Activity payloads are projected for clients as they are read in small + * sequential batches. Callers still apply the full snapshot projector for + * collection-level activity pruning. */ readonly getThreadDetailSnapshot: ( threadId: ThreadId, From 0bfb6df34b26dfe0162db6c09dca00bc8c5a5ec4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 02:12:35 -0700 Subject: [PATCH 011/136] perf(server): cut idle CPU use and stop provider event leaks (#8187) --- .../Layers/ProviderRuntimeIngestion.test.ts | 23 ++ .../Layers/ProviderRuntimeIngestion.ts | 18 +- .../RepositoryIdentityResolver.test.ts | 84 ++++++ .../src/project/RepositoryIdentityResolver.ts | 32 ++- .../provider/Layers/EventNdjsonLogger.test.ts | 44 ++- .../src/provider/Layers/EventNdjsonLogger.ts | 61 ++++- .../provider/Layers/OpenCodeAdapter.test.ts | 15 ++ .../src/provider/Layers/OpenCodeAdapter.ts | 6 +- .../src/provider/acp/AcpNativeLogging.test.ts | 169 ++++++++++++ .../src/provider/acp/AcpNativeLogging.ts | 67 ++++- .../NativeTelemetryClient.test.ts | 5 +- .../NativeTelemetryClient.ts | 40 +-- apps/server/src/vcs/GitVcsDriverCore.ts | 3 +- docs/internals/resource-telemetry.md | 9 +- native/resource-monitor/src/main.rs | 35 ++- packages/effect-acp/src/protocol.test.ts | 35 +++ packages/effect-acp/src/protocol.ts | 18 +- .../src/protocol.test.ts | 252 ++++++++++++++++++ .../effect-codex-app-server/src/protocol.ts | 95 +++++-- 19 files changed, 913 insertions(+), 98 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 84858b6affe93..26332f9f8c9c1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -973,6 +973,29 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("ignores provider content deltas that cannot change thread state", async () => { + const harness = await createHarness(); + const initial = await harness.readModel(); + + for (const streamKind of ["reasoning_text", "command_output", "file_change_output"] as const) { + harness.emit({ + type: "content.delta", + eventId: asEventId(`evt-ignored-${streamKind}`), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-ignored"), + payload: { + streamKind, + delta: "ignored output", + }, + }); + } + + await harness.drain(); + expect(await harness.readModel()).toEqual(initial); + }); + it("maps canonical content delta/item completed into finalized assistant messages", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 198f163d8a891..a90010f0b6e23 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1499,6 +1499,10 @@ const make = Effect.gen(function* () { const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { + if (event.type === "content.delta" && event.payload.streamKind !== "assistant_text") { + return; + } + const thread = yield* resolveThreadShell(event.threadId); if (!thread) return; @@ -1515,9 +1519,17 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; - const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ - threadId: thread.id, - }); + const pendingTurnStart = + event.type === "session.started" || + event.type === "session.state.changed" || + event.type === "session.exited" || + event.type === "thread.started" || + event.type === "turn.started" || + event.type === "turn.completed" + ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId: thread.id, + }) + : Option.none(); const hasPendingTurnStart = Option.isSome(pendingTurnStart) && thread.session?.status === "starting"; diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index a997459e63d7e..72232a78b689e 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { TestClock } from "effect/testing"; import * as ProcessRunner from "../processRunner.ts"; @@ -35,6 +36,89 @@ const makeRepositoryIdentityResolverTestLayer = (options: { ).pipe(Layer.provide(ProcessRunner.layer)); it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { + it.effect("reuses the cached Git root for repeated workspace lookups", () => { + const calls: Array> = []; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.sync(() => { + calls.push(input.args); + return { + stdout: input.args.includes("rev-parse") + ? "/repo\n" + : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const first = yield* resolver.resolve("/repo/packages/web"); + const second = yield* resolver.resolve("/repo/packages/web"); + + expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(second).toEqual(first); + expect(calls).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo", "remote", "-v"], + ]); + }).pipe(Effect.provide(resolverLayer)); + }); + + it.effect("retries Git root discovery after a failed lookup", () => { + const calls: Array> = []; + let rootAttempts = 0; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.sync(() => { + calls.push(input.args); + const rootLookup = input.args.includes("rev-parse"); + const failed = rootLookup && rootAttempts++ === 0; + return { + stdout: rootLookup + ? failed + ? "" + : "/repo\n" + : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + stderr: failed ? "temporary Git failure" : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + + const recovered = yield* resolver.resolve("/repo/packages/web"); + expect(recovered?.rootPath).toBe("/repo"); + expect(calls).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo", "remote", "-v"], + ]); + }).pipe(Effect.provide(resolverLayer)); + }); + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 50608e7704c7f..bf3c570c3cacc 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -90,7 +90,6 @@ function buildRepositoryIdentity(input: { const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. @@ -102,15 +101,11 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; + return null; } const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } - - return cacheKey; + return candidate.length > 0 ? candidate : null; }, ); @@ -139,6 +134,22 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; + + const repositoryRootCache = yield* Cache.makeWith( + (cwd) => + resolveRepositoryIdentityCacheKey(cwd).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), + { + capacity: cacheCapacity, + timeToLive: Exit.match({ + onSuccess: (value) => + value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }, + ); const repositoryIdentityCache = yield* Cache.makeWith( (cacheKey) => @@ -146,7 +157,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), { - capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + capacity: cacheCapacity, timeToLive: Exit.match({ onSuccess: (value) => value === null @@ -160,9 +171,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", )(function* (cwd) { - const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ); + const cacheKey = yield* Cache.get(repositoryRootCache, cwd); + if (cacheKey === null) return null; return yield* Cache.get(repositoryIdentityCache, cacheKey); }); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index f6fb557e4b432..c072e6e5148f4 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -286,7 +286,7 @@ describe("EventNdjsonLogger", () => { }), ); - it.effect("drops transient canonical events before serialization", () => + it.effect("drops transient provider events before serialization", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); const basePath = NodePath.join(tempDir, "events.log"); @@ -302,6 +302,46 @@ describe("EventNdjsonLogger", () => { yield* canonical.write(circularDelta, threadId); yield* canonical.write({ type: "item.completed", id: "final" }, threadId); yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); + yield* native.write( + { method: "item/agentMessage/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/transcript/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { + event: { + method: "claude/stream_event/content_block_delta/text_delta", + payload: circularDelta, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + method: "session/update", + payload: { update: { sessionUpdate: "agent_message_chunk" } }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + type: "message.part.updated", + payload: { properties: { part: { type: "text" } } }, + }, + }, + threadId, + ); + yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); yield* store.close(); const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") @@ -313,7 +353,7 @@ describe("EventNdjsonLogger", () => { lines.map(({ stream, payload }) => ({ stream, payload })), [ { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, - { stream: "NTIVE", payload: '{"type":"content.delta","id":"native-delta"}' }, + { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, ], ); } finally { diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index e07121ea76c1d..241eddb3b9cb6 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -45,6 +45,17 @@ const transientCanonicalEventTypes = new Set([ "tool.progress", "turn.proposed.delta", ]); +const transientNativeMethods = new Set([ + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/plan/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "thread/realtime/outputAudio/delta", + "thread/realtime/transcript/delta", +]); +const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); export type EventNdjsonStream = "native" | "canonical" | "orchestration"; @@ -126,7 +137,7 @@ export interface PendingRecord { } interface StoreState { - readonly pending: ReadonlyArray; + readonly pending: Array; readonly pendingBytes: number; readonly sinks: ReadonlyMap; readonly flushScheduled: boolean; @@ -178,12 +189,50 @@ function providerLogPath(directory: string, prefix: string, threadSegment: strin } function shouldPersist(stream: EventNdjsonStream, event: unknown): boolean { - if (stream !== "canonical" || typeof event !== "object" || event === null) { + if (stream === "orchestration" || typeof event !== "object" || event === null) { return true; } try { const type = Reflect.get(event, "type"); - return typeof type !== "string" || !transientCanonicalEventTypes.has(type); + if (typeof type === "string" && transientCanonicalEventTypes.has(type)) { + return false; + } + if (stream !== "native") return true; + + const nested = Reflect.get(event, "event"); + const nativeEvent = typeof nested === "object" && nested !== null ? nested : event; + const method = Reflect.get(nativeEvent, "method"); + if ( + typeof method === "string" && + (transientNativeMethods.has(method) || + method.startsWith("claude/stream_event/content_block_delta/")) + ) { + return false; + } + + const nativeType = Reflect.get(nativeEvent, "type"); + if (nativeType === "message.part.delta") return false; + + const payload = Reflect.get(nativeEvent, "payload"); + if (typeof payload !== "object" || payload === null) return true; + + if (method === "session/update") { + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return true; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType !== "string" || !transientAcpUpdates.has(updateType); + } + + if (nativeType === "message.part.updated") { + const properties = Reflect.get(payload, "properties"); + if (typeof properties !== "object" || properties === null) return true; + const part = Reflect.get(properties, "part"); + if (typeof part !== "object" || part === null) return true; + const partType = Reflect.get(part, "type"); + return partType !== "text" && partType !== "reasoning"; + } + + return true; } catch { return true; } @@ -566,10 +615,8 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( if (state.closed) { return Effect.succeed([{ flush: false }, state] as const); } - const pending = [ - ...state.pending, - { stream, threadSegment: resolveThreadSegment(threadId), line, bytes }, - ]; + const pending = state.pending; + pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); const pendingBytes = state.pendingBytes + bytes; const flush = resolved.batchWindowMs === 0 || diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9823a68708c2e..0ba4b2d1ee3d3 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -4607,12 +4607,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); + const appendedUpdate = mergeOpenCodeAssistantText("Hello", "Hello world"); + const changedUpdate = mergeOpenCodeAssistantText("Hello world", "Hello there"); + const staleUpdate = mergeOpenCodeAssistantText("Hello world", "Hello"); NodeAssert.deepEqual( [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], ["Hello", "lo world", ""], ); NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); + NodeAssert.deepEqual(appendedUpdate, { + latestText: "Hello world", + deltaToEmit: " world", + }); + NodeAssert.deepEqual(changedUpdate, { + latestText: "Hello there", + deltaToEmit: "there", + }); + NodeAssert.deepEqual(staleUpdate, { + latestText: "Hello world", + deltaToEmit: "", + }); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index c049eedb62b5e..606dcde6ce9eb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -565,9 +565,13 @@ export function mergeOpenCodeAssistantText( readonly deltaToEmit: string; } { const latestText = resolveLatestAssistantText(previousText, nextText); + const previous = previousText ?? ""; + const prefixLength = latestText.startsWith(previous) + ? previous.length + : commonPrefixLength(previous, latestText); return { latestText, - deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), + deltaToEmit: latestText.slice(prefixLength), }; } diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts index 7c949e0405997..84926fbe1d614 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.test.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.test.ts @@ -28,6 +28,7 @@ nodeServicesIt("ACP native logging", (it) => { nativeEventLogger, provider: ProviderDriverKind.make("cursor"), threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, }); const secret = "secret-token-value"; const requestLogger = logger.requestLogger; @@ -67,6 +68,174 @@ nodeServicesIt("ACP native logging", (it) => { }), ); + it.effect("keeps request diagnostics without enabling full protocol logging", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + }); + + assert.isUndefined(logger.protocolLogging); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("drops transient ACP chunks before formatting verbose protocol logs", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + for (const updateType of ["agent_message_chunk", "agent_thought_chunk"] as const) { + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: updateType } }, + })}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: updateType } }, + }, + ], + }); + } + + assert.lengthOf(records, 0); + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "tool_call" } }, + }, + ], + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("keeps mixed and incomplete raw diagnostics", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + const transient = encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: "agent_message_chunk" } }, + }); + const lifecycle = encodeUnknownJson({ method: "session/new", params: {} }); + + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n${lifecycle}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: transient, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n{malformed}\n`, + }); + + assert.lengthOf(records, 3); + }), + ); + + it.effect("filters transient entries from mixed decoded batches", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "agent_thought_chunk" } }, + }, + { + _tag: "Request", + tag: "session/new", + payload: {}, + }, + ], + }); + + assert.lengthOf(records, 1); + assert.include(encodeUnknownJson(records), '"itemCount":1'); + }), + ); + it.effect("logs a structural tag when the native writer defects", () => { const messages: Array = []; const logCapture = Logger.make(({ message }) => { diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 06bff3aa6113d..6d1bf6209d5da 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -9,6 +9,8 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); + function structuralMethod(value: string): string { return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; } @@ -64,12 +66,61 @@ function formatProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) }; } +function isTransientProtocolMessage(message: unknown): boolean { + if (typeof message !== "object" || message === null) return false; + const method = Reflect.get(message, "tag") ?? Reflect.get(message, "method"); + if (method !== "session/update") return false; + + const payload = Reflect.get(message, "payload") ?? Reflect.get(message, "params"); + if (typeof payload !== "object" || payload === null) return false; + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return false; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType === "string" && transientProtocolUpdates.has(updateType); +} + +function rawChunkContainsOnlyTransientMessages(payload: string): boolean { + const lines = payload.split("\n"); + const remainder = lines.pop() ?? ""; + if (remainder.trim().length > 0) return false; + + const messages: Array = []; + for (const line of lines) { + if (line.trim().length === 0) continue; + try { + messages.push(JSON.parse(line)); + } catch { + return false; + } + } + return messages.length > 0 && messages.every(isTransientProtocolMessage); +} + +function filterTransientProtocolLog( + event: EffectAcpProtocol.AcpProtocolLogEvent, +): EffectAcpProtocol.AcpProtocolLogEvent | undefined { + if (event.direction !== "incoming") return event; + + if (event.stage === "raw" && typeof event.payload === "string") { + return rawChunkContainsOnlyTransientMessages(event.payload) ? undefined : event; + } + + if (event.stage !== "decoded") return event; + if (!Array.isArray(event.payload)) { + return isTransientProtocolMessage(event.payload) ? undefined : event; + } + + const payload = event.payload.filter((message) => !isTransientProtocolMessage(message)); + return payload.length === 0 ? undefined : { ...event, payload }; +} + export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { const crypto = yield* Crypto.Crypto; return (input: { readonly nativeEventLogger: EventNdjsonLogger | undefined; readonly provider: ProviderDriverKind; readonly threadId: ThreadId; + readonly verboseProtocolLogging?: boolean; }): Pick => { const writeNativeAcpLog = (logInput: { readonly kind: "request" | "protocol"; @@ -111,16 +162,20 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" kind: "request", payload: formatRequestLogPayload(event), }), - ...(input.nativeEventLogger + ...(input.nativeEventLogger && input.verboseProtocolLogging ? { protocolLogging: { logIncoming: true, logOutgoing: true, - logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => - writeNativeAcpLog({ - kind: "protocol", - payload: formatProtocolLogPayload(event), - }), + logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { + const filtered = filterTransientProtocolLog(event); + return filtered + ? writeNativeAcpLog({ + kind: "protocol", + payload: formatProtocolLogPayload(filtered), + }) + : Effect.void; + }, } satisfies NonNullable, } : {}), diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 61a67d116069e..8a595bc8b480a 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -44,7 +44,7 @@ describe("resolveNativeSampleIntervalMs", () => { expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); }); - it("keeps unknown background telemetry cheap but serves live diagnostics at 1Hz", () => { + it("slows background telemetry and serves live diagnostics at 1Hz", () => { const unknown: HostPowerSnapshot = { ...basePower, source: "unknown", @@ -58,7 +58,8 @@ describe("resolveNativeSampleIntervalMs", () => { 0, ), ).toBe(5_000); - expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(1_000); + expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(5_000); + expect(resolveNativeSampleIntervalMs(basePower, 1)).toBe(1_000); }); }); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index e8d81cc4c1c0c..232079d9dc9bf 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -268,7 +268,7 @@ export function resolveNativeSampleIntervalMs( return CONSTRAINED_SAMPLE_INTERVAL_MS; } if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS; - return SAMPLE_INTERVAL_MS; + return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS; } export function commitCollectionControlUpdate( @@ -462,13 +462,16 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu return Effect.gen(function* () { const nativeSnapshot = { generation, snapshot: event } satisfies NativeTelemetrySnapshot; const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs); - yield* Ref.update(state, (current) => ({ - ...current, - status: "healthy" as const, - lastSampleAt: Option.some(sampledAt), - lastError: Option.none(), - })); - yield* publishHealth; + const healthChanged = yield* Ref.modify(state, (current) => [ + current.status !== "healthy" || Option.isSome(current.lastError), + { + ...current, + status: "healthy" as const, + lastSampleAt: Option.some(sampledAt), + lastError: Option.none(), + }, + ]); + if (healthChanged) yield* publishHealth; yield* PubSub.publish(snapshots, nativeSnapshot); if (event.requestId) { const deferred = yield* Ref.modify(pendingSamples, (pending) => { @@ -485,15 +488,18 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu case "historyChunk": return Effect.gen(function* () { const latestSnapshot = event.snapshots.at(-1); - yield* Ref.update(state, (current) => ({ - ...current, - status: "healthy" as const, - lastSampleAt: latestSnapshot - ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) - : current.lastSampleAt, - lastError: Option.none(), - })); - yield* publishHealth; + const healthChanged = yield* Ref.modify(state, (current) => [ + current.status !== "healthy" || Option.isSome(current.lastError), + { + ...current, + status: "healthy" as const, + lastSampleAt: latestSnapshot + ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) + : current.lastSampleAt, + lastError: Option.none(), + }, + ]); + if (healthChanged) yield* publishHealth; const completed = yield* Ref.modify(pendingHistories, (pending) => { const request = pending.get(event.requestId); if (!request) return [Option.none(), pending] as const; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 71e478cbaa3d8..ef2d00291cafd 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -475,6 +475,7 @@ function trace2ChildKey(record: Record): string | null { } const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); +const decodeTrace2Record = decodeJsonResult(Trace2Record); const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( input: Pick, @@ -509,7 +510,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( return; } - const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); + const traceRecord = decodeTrace2Record(trimmedLine); if (Result.isFailure(traceRecord)) { yield* Effect.logDebug( `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, diff --git a/docs/internals/resource-telemetry.md b/docs/internals/resource-telemetry.md index 0d07f31f8ac51..0f3e6674ff79b 100644 --- a/docs/internals/resource-telemetry.md +++ b/docs/internals/resource-telemetry.md @@ -103,9 +103,9 @@ power-adaptive interval selected by the server. It collects: - resident and virtual memory; - cumulative process I/O counters. -On Linux, task/thread enumeration is disabled. Command lines are loaded only -when first needed. This avoids the expensive default behavior of walking every -`/proc//task/` directory on each refresh. +On Linux, task/thread enumeration is disabled. Command lines are refreshed with +each sample so process replacements remain visible. Disabling task enumeration +avoids walking every `/proc//task/` directory on each refresh. ### Process-tree selection @@ -142,7 +142,8 @@ The server adjusts native sampling without restarting the sidecar: - suspended, locked, low-power, or serious/critical thermal state: 15 seconds; - battery: 5 seconds; -- normal AC: 1 second; +- normal AC: 5 seconds in the background and 1 second while live diagnostics is + open; - unknown or stale power: 5 seconds in the background and 1 second while live diagnostics is open. diff --git a/native/resource-monitor/src/main.rs b/native/resource-monitor/src/main.rs index 0e5dd66307b9b..0596aea977cdf 100644 --- a/native/resource-monitor/src/main.rs +++ b/native/resource-monitor/src/main.rs @@ -250,6 +250,10 @@ impl HistoryRecorder { max_retained_entries: usize, max_retained_bytes: usize, ) { + let clock_moved_backward = self + .snapshots + .back() + .is_some_and(|previous| previous.sampled_at_unix_ms > snapshot.sampled_at_unix_ms); let mut retained = snapshot.clone(); retained.request_id = None; self.retained_entry_count = self @@ -264,6 +268,7 @@ impl HistoryRecorder { max_snapshots, max_retained_entries, max_retained_bytes, + clock_moved_backward, ); } @@ -273,20 +278,24 @@ impl HistoryRecorder { max_snapshots: usize, max_retained_entries: usize, max_retained_bytes: usize, + clock_moved_backward: bool, ) { - let mut future_entry_count = 0usize; - let mut future_bytes = 0usize; - self.snapshots.retain(|snapshot| { - let keep = snapshot.sampled_at_unix_ms <= now_ms; - if !keep { - future_entry_count = - future_entry_count.saturating_add(snapshot.retained_entry_count()); - future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes()); - } - keep - }); - self.retained_entry_count = self.retained_entry_count.saturating_sub(future_entry_count); - self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes); + if clock_moved_backward { + let mut future_entry_count = 0usize; + let mut future_bytes = 0usize; + self.snapshots.retain(|snapshot| { + let keep = snapshot.sampled_at_unix_ms <= now_ms; + if !keep { + future_entry_count = + future_entry_count.saturating_add(snapshot.retained_entry_count()); + future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes()); + } + keep + }); + self.retained_entry_count = + self.retained_entry_count.saturating_sub(future_entry_count); + self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes); + } while self.snapshots.front().is_some_and(|snapshot| { snapshot.sampled_at_unix_ms < now_ms.saturating_sub(HISTORY_RETENTION_MS) diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index a66cc75225dab..7ca86b063e23f 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -135,6 +135,41 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("keeps only recent raw notifications after their callbacks run", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Deferred.make(); + let handledCount = 0; + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onNotification: () => + Effect.sync(() => ++handledCount).pipe( + Effect.flatMap((count) => + count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ), + }); + + const messages = Array.from({ length: 64 }, (_, index) => + encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "x/performance", + params: { index }, + }), + ); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + yield* Deferred.await(handled); + + const retained = yield* transport.incoming.pipe(Stream.take(32), Stream.runCollect); + + assert.equal(handledCount, 64); + assert.equal(retained.length, 32); + assert.deepEqual(retained[0]?.params, { index: 32 }); + assert.deepEqual(retained[31]?.params, { index: 63 }); + }), + ); + it.effect("keeps invalid core notification values only in the schema cause", () => Effect.gen(function* () { const secret = "acp-core-notification-secret-sentinel"; diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index d61641fbb7b52..44a48bd1ef760 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -76,6 +76,7 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( AcpSchema.ElicitationCompleteNotification, ); const parserFactory = RpcSerialization.ndJsonRpc(); +const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( options: AcpPatchedProtocolOptions, @@ -83,7 +84,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const parser = parserFactory.makeUnsafe(); const serverQueue = yield* Queue.unbounded(); const clientQueue = yield* Queue.unbounded(); - const notificationQueue = yield* Queue.unbounded(); + const notificationQueue = yield* Queue.sliding( + MAX_BUFFERED_RAW_NOTIFICATIONS, + ); const disconnects = yield* Queue.unbounded(); const outgoing = yield* Queue.unbounded>(); const nextRequestId = yield* Ref.make(1); @@ -408,11 +411,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi yield* options.stdio.stdin.pipe( Stream.runForEach((data) => - logProtocol({ - direction: "incoming", - stage: "raw", - payload: typeof data === "string" ? data : new TextDecoder().decode(data), - }).pipe( + (options.logIncoming + ? logProtocol({ + direction: "incoming", + stage: "raw", + payload: typeof data === "string" ? data : new TextDecoder().decode(data), + }) + : Effect.void + ).pipe( Effect.flatMap(() => Effect.try({ try: () => diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index c0eacf1cf7578..7249afff10713 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -398,6 +399,257 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect("keeps only recent raw notifications after their callbacks run", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Deferred.make(); + let handledCount = 0; + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onNotification: () => + Effect.sync(() => ++handledCount).pipe( + Effect.flatMap((count) => + count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ), + }); + + const messages = Array.from({ length: 64 }, (_, index) => + encodeUnknownJsonString({ + method: "item/agentMessage/delta", + params: { index }, + }), + ); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + yield* Deferred.await(handled); + + const retained = yield* transport.incomingNotifications.pipe( + Stream.take(32), + Stream.runCollect, + ); + + assert.equal(handledCount, 64); + assert.equal(retained.length, 32); + assert.deepEqual(retained[0]?.params, { index: 32 }); + assert.deepEqual(retained[31]?.params, { index: 63 }); + }), + ); + + it.effect("keeps processing protocol messages while an approval is pending", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const approvalStarted = yield* Deferred.make(); + const approvalDecision = yield* Deferred.make<{ readonly decision: string }>(); + const notificationReceived = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(approvalStarted, undefined).pipe( + Effect.andThen(Deferred.await(approvalDecision)), + ), + onNotification: () => Deferred.succeed(notificationReceived, undefined).pipe(Effect.asVoid), + }); + + const pendingRequest = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encoder.encode( + `${[ + encodeUnknownJsonString({ id: 7, method: "item/tool/requestUserInput", params: {} }), + encodeUnknownJsonString({ method: "item/agentMessage/delta", params: { delta: "ok" } }), + encodeUnknownJsonString({ id: 1, result: { threadId: "thread-1" } }), + ].join("\n")}\n`, + ), + ); + + yield* Deferred.await(approvalStarted); + yield* Deferred.await(notificationReceived); + assert.deepEqual(yield* Fiber.join(pendingRequest), { threadId: "thread-1" }); + + yield* Deferred.succeed(approvalDecision, { decision: "accept" }); + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 7, + result: { decision: "accept" }, + }); + }), + ); + + it.effect("rejects incoming requests after the active handler limit is reached", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const handlersStarted = yield* Deferred.make(); + const releaseHandlers = yield* Deferred.make(); + let activeHandlers = 0; + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Effect.sync(() => ++activeHandlers).pipe( + Effect.flatMap((count) => + count === 32 + ? Deferred.succeed(handlersStarted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.andThen(Deferred.await(releaseHandlers)), + Effect.as({ decision: "accept" }), + ), + }); + + const requests = Array.from({ length: 33 }, (_, index) => + encodeUnknownJsonString({ + id: index + 1, + method: "item/tool/requestUserInput", + params: {}, + }), + ); + yield* Queue.offer(input, encoder.encode(`${requests.join("\n")}\n`)); + yield* Deferred.await(handlersStarted); + + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 33, + error: { + code: -32001, + message: "Too many Codex requests are already active.", + }, + }); + assert.equal(activeHandlers, 32); + + yield* Deferred.succeed(releaseHandlers, undefined); + yield* Effect.forEach(Array.from({ length: 32 }), () => Queue.take(output), { + discard: true, + }); + }), + ); + + it.effect("interrupts pending request handlers when the protocol terminates", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const approvalStarted = yield* Deferred.make(); + const approvalInterrupted = yield* Deferred.make(); + const terminated = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(approvalStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(approvalInterrupted, undefined).pipe(Effect.asVoid), + ), + ), + onTermination: () => Deferred.succeed(terminated, undefined).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: 7, method: "item/tool/requestUserInput", params: {} }), + ); + yield* Deferred.await(approvalStarted); + yield* Queue.end(input); + + yield* Deferred.await(approvalInterrupted); + yield* Deferred.await(terminated); + }), + ); + + it.effect("rejects outgoing messages after an approval response cannot be encoded", () => + Effect.gen(function* () { + const { stdio: baseStdio, input } = yield* makeInMemoryStdio(); + const terminated = yield* Deferred.make(); + const readerStopped = yield* Deferred.make(); + let notificationCount = 0; + let requestCount = 0; + const stdio = Stdio.make({ + args: baseStdio.args, + stdin: baseStdio.stdin.pipe( + Stream.ensuring(Deferred.succeed(readerStopped, undefined).pipe(Effect.asVoid)), + ), + stdout: baseStdio.stdout, + stderr: baseStdio.stderr, + }); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Effect.sync(() => ++requestCount).pipe( + Effect.map((count) => (count === 1 ? { invalid: 1n } : { ok: true })), + ), + onNotification: () => Effect.sync(() => notificationCount++).pipe(Effect.asVoid), + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: 7, method: "item/tool/requestUserInput", params: {} }), + ); + + const failure = yield* Deferred.await(terminated); + assert.instanceOf(failure, CodexError.CodexAppServerProtocolParseError); + const requestFailure = yield* transport.request("thread/read", {}).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected a terminated protocol request to fail"), + }), + ); + const notificationFailure = yield* transport.notify("initialized").pipe(Effect.flip); + assert.strictEqual(requestFailure, failure); + assert.strictEqual(notificationFailure, failure); + yield* Deferred.await(readerStopped); + + yield* Queue.offer( + input, + encoder.encode( + `${[ + encodeUnknownJsonString({ method: "x/late-notification" }), + encodeUnknownJsonString({ id: 8, method: "x/late-request" }), + ].join("\n")}\n`, + ), + ); + + assert.equal(notificationCount, 0); + assert.equal(requestCount, 1); + assert.equal(yield* Queue.size(input), 1); + }), + ); + + it.effect("fails pending requests before interrupted handler cleanup completes", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const handlerStarted = yield* Deferred.make(); + const finalizerStarted = yield* Deferred.make(); + const releaseFinalizer = yield* Deferred.make(); + const terminated = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(handlerStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(finalizerStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFinalizer)), + ), + ), + ), + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + const pending = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer(input, encodeJsonl({ id: 7, method: "x/approval" })); + yield* Deferred.await(handlerStarted); + yield* Queue.end(input); + + const failure = yield* Deferred.await(terminated); + yield* Deferred.await(finalizerStarted); + const pendingFailure = yield* Fiber.join(pending).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected the pending request to fail"), + }), + ); + assert.strictEqual(pendingFailure, failure); + + yield* Deferred.succeed(releaseFinalizer, undefined); + }), + ); + it.effect("surfaces JSON encoding failures as protocol parse errors", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index 2f604c866e5c2..4a32973a988bd 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -1,6 +1,8 @@ import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; @@ -13,6 +15,7 @@ import { JsonRpcId, JsonRpcResponseEnvelope } from "./_internal/shared.ts"; const isJsonRpcId = Schema.is(JsonRpcId); const isJsonRpcResponseEnvelope = Schema.is(JsonRpcResponseEnvelope); const isCodexAppServerError = Schema.is(CodexError.CodexAppServerError); +const MAX_BUFFERED_RAW_MESSAGES = 32; export interface CodexAppServerProtocolLogEvent { readonly direction: "incoming" | "outgoing"; @@ -152,13 +155,20 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa function* ( options: CodexAppServerPatchedProtocolOptions, ): Effect.fn.Return { + const protocolScope = yield* Scope.Scope; + const requestHandlerScope = yield* Scope.fork(protocolScope, "parallel"); const outgoing = yield* Queue.unbounded>(); - const incomingNotifications = yield* Queue.unbounded(); - const incomingRequests = yield* Queue.unbounded(); + const incomingNotifications = + yield* Queue.sliding(MAX_BUFFERED_RAW_MESSAGES); + const incomingRequests = + yield* Queue.sliding(MAX_BUFFERED_RAW_MESSAGES); const pending = yield* Ref.make(new Map()); const nextRequestId = yield* Ref.make(1); const remainder: Array = []; const terminationHandled = yield* Ref.make(false); + const terminationFailure = yield* Ref.make(Option.none()); + const terminationSignal = yield* Deferred.make(); + const activeRequestHandlers = yield* Ref.make(0); const logProtocol = (event: CodexAppServerProtocolLogEvent) => { if (event.direction === "incoming" && !options.logIncoming) { @@ -191,8 +201,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa return [ Effect.gen(function* () { const error = yield* classify(); + yield* Ref.set(terminationFailure, Option.some(error)); yield* failAllPending(error); yield* Queue.end(outgoing); + yield* Deferred.succeed(terminationSignal, undefined); + yield* Scope.close(requestHandlerScope, Exit.void).pipe( + Effect.forkIn(protocolScope, { startImmediately: true }), + Effect.asVoid, + ); if (options.onTermination) { yield* options.onTermination(error); } @@ -203,6 +219,9 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const offerOutgoing = (message: Record) => Effect.gen(function* () { + const failure = yield* Ref.get(terminationFailure); + if (Option.isSome(failure)) return yield* failure.value; + yield* logProtocol({ direction: "outgoing", stage: "decoded", @@ -214,7 +233,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa stage: "raw", payload: encoded, }); - yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid); + const accepted = yield* Queue.offer(outgoing, encoded); + if (!accepted) { + const closed = yield* Ref.get(terminationFailure); + return yield* Option.getOrElse( + closed, + () => new CodexError.CodexAppServerInputStreamEndedError({}), + ); + } }); const removePending = (requestId: string) => @@ -271,9 +297,24 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const handleRequest = (request: CodexAppServerIncomingRequest) => Queue.offer(incomingRequests, request).pipe( - Effect.andThen( - options.onRequest - ? options.onRequest(request).pipe( + Effect.flatMap(() => { + const handler = options.onRequest; + if (!handler) return Effect.void; + + return Ref.modify(activeRequestHandlers, (count) => + count >= MAX_BUFFERED_RAW_MESSAGES ? [false, count] : [true, count + 1], + ).pipe( + Effect.flatMap((accepted) => { + if (!accepted) { + return respondError( + request.id, + CodexError.CodexAppServerRequestError.overloaded( + "Too many Codex requests are already active.", + ), + ); + } + + return handler(request).pipe( Effect.matchEffect({ onFailure: (error) => respondError( @@ -285,9 +326,21 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa ), onSuccess: (result) => respond(request.id, result), }), - ) - : Effect.void, - ), + Effect.ensuring( + Ref.update(activeRequestHandlers, (count) => Math.max(0, count - 1)), + ), + Effect.catch((error) => + handleTermination(() => Effect.succeed(error)).pipe( + Effect.forkIn(protocolScope), + Effect.asVoid, + ), + ), + Effect.forkIn(requestHandlerScope, { startImmediately: true }), + Effect.asVoid, + ); + }), + ); + }), Effect.asVoid, ); @@ -297,22 +350,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.asVoid, ); - const routeMessage = ( - message: unknown, - ): Effect.Effect => { - if (isIncomingRequest(message)) { - return handleRequest(message); - } - if (isIncomingNotification(message)) { - return handleNotification(message); - } - if (isIncomingResponse(message)) { - return handleResponse(message); - } - return Effect.fail( - CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message), - ); - }; + const routeMessage = Effect.fnUntraced(function* (message: unknown) { + if (Option.isSome(yield* Ref.get(terminationFailure))) return; + if (isIncomingRequest(message)) return yield* handleRequest(message); + if (isIncomingNotification(message)) return yield* handleNotification(message); + if (isIncomingResponse(message)) return yield* handleResponse(message); + return yield* CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message); + }); const handleLine = (line: string): Effect.Effect => { if (line.trim().length === 0) { @@ -352,6 +396,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa }; yield* options.stdio.stdin.pipe( + Stream.interruptWhen(Deferred.await(terminationSignal)), Stream.decodeText(), Stream.runForEach((chunk) => Effect.sync(() => { From 8f1ef8b9eb72ac6cd8510364608e01dc7febf9c1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:59:16 +0200 Subject: [PATCH 012/136] perf(server): scan only appended transcript bytes for usage summaries (#9024) Co-authored-by: Claude Fable 5 Co-authored-by: Theo Browne --- apps/server/src/usage/UsageService.test.ts | 226 ++++++++++++++++++ apps/server/src/usage/UsageService.ts | 165 ++++++++++--- apps/server/src/usage/usageScanCache.test.ts | 75 +++++- apps/server/src/usage/usageScanCache.ts | 176 +++++++++++--- .../src/usage/usageTranscriptReader.test.ts | 210 ++++++++++++++++ .../server/src/usage/usageTranscriptReader.ts | 201 ++++++++++++++-- 6 files changed, 963 insertions(+), 90 deletions(-) create mode 100644 apps/server/src/usage/UsageService.test.ts create mode 100644 apps/server/src/usage/usageTranscriptReader.test.ts diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts new file mode 100644 index 0000000000000..8fc86ee3d462a --- /dev/null +++ b/apps/server/src/usage/UsageService.test.ts @@ -0,0 +1,226 @@ +// @effect-diagnostics nodeBuiltinImport:off - the suite seeds and grows real +// transcript trees on disk, outside the service's Effect FileSystem. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scheduler from "effect/Scheduler"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as UsageService from "./UsageService.ts"; + +function claudeLine(id: number, outputTokens: number): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: outputTokens }, + }, + })}\n`; +} + +const WINDOW: UsageSummaryInput = { + timeZone: "UTC", + sinceDay: UsageDay.make("2026-07-31"), + untilDay: UsageDay.make("2026-08-02"), +}; + +const setup = Effect.gen(function* () { + const home = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-service-test-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => NodeFSP.rm(home, { recursive: true, force: true })), + ); + const transcriptDir = NodePath.join(home, "claude", "projects", "proj"); + yield* Effect.promise(() => NodeFSP.mkdir(transcriptDir, { recursive: true })); + return { + home, + transcript: NodePath.join(transcriptDir, "session.jsonl"), + settings: { + providers: { + claudeAgent: { homePath: NodePath.join(home, "claude") }, + codex: { homePath: NodePath.join(home, "codex") }, + }, + }, + }; +}); + +const serviceLayers = (input: { + readonly prefix: string; + readonly home: string; + readonly settings: Parameters[0]; + readonly onRatesFetch?: () => void; +}) => + ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettings.layerTest(input.settings)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + input.onRatesFetch?.(); + // Unparsable rates: every scan retries the fetch, which makes the + // fetch count a boundary-level observation of how many scans ran. + return HttpClientResponse.fromWeb(request, Response.json({})); + }), + ), + ), + ), + Layer.provideMerge( + Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), + ), + ); + +function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { + return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); +} + +describe("UsageService", () => { + it.live("counts appended usage on a rescan of a grown transcript", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-grow-test", home, settings })), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const second = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(second), 12); + }).pipe(Effect.scoped), + ); + + it.live("shares one scan between concurrent identical requests", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-flight-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + const [first, second] = yield* Effect.all( + [service.readSummary(WINDOW), service.readSummary(WINDOW)], + { concurrency: 2 }, + ); + assert.deepStrictEqual(first, second); + assert.strictEqual(ratesFetches, 1); + + // A later request is fresh work again, not a stale cached answer. + yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 2); + }).pipe(Effect.scoped), + ); + + it.live("does not orphan an in-flight scan when its first caller is interrupted", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-interruption-test", home, settings }), + ), + ); + + let orphanedAt: number | undefined; + for (let interruptAt = 1; interruptAt <= 31; interruptAt += 1) { + const tasks: Array<() => void> = []; + const dispatcher: Scheduler.SchedulerDispatcher = { + scheduleTask: (task) => tasks.push(task), + flush: () => { + let task: (() => void) | undefined; + while ((task = tasks.shift()) !== undefined) task(); + }, + }; + + let requestFiber: Fiber.Fiber | undefined; + let requestChecks = 0; + const scheduler: Scheduler.Scheduler = { + executionMode: "async", + makeDispatcher: () => dispatcher, + shouldYield: (fiber) => { + if (fiber !== requestFiber) return false; + requestChecks += 1; + if (requestChecks !== interruptAt) return false; + fiber.interruptUnsafe(); + return true; + }, + }; + + // Each candidate needs a distinct key because the broken case leaves + // its entry in the service's private in-flight map. The invalid window + // keeps the real scan synchronous once its detached fiber starts. + const input: UsageSummaryInput = { + ...WINDOW, + sinceDay: UsageDay.make("2026-09-01"), + untilDay: UsageDay.make(`2026-08-${String(interruptAt).padStart(2, "0")}`), + }; + const first = yield* service + .readSummary(input) + .pipe( + Effect.exit, + Effect.provideService(Scheduler.Scheduler, scheduler), + Effect.forkChild, + ); + requestFiber = first; + yield* Effect.yieldNow; + dispatcher.flush(); + + const second = yield* service.readSummary(input).pipe( + Effect.match({ + onFailure: (error) => error.reason, + onSuccess: () => "success" as const, + }), + Effect.provideService(Scheduler.Scheduler, scheduler), + Effect.forkChild, + ); + yield* Effect.yieldNow; + dispatcher.flush(); + const secondExit = second.pollUnsafe(); + if (secondExit === undefined) { + second.interruptUnsafe(); + orphanedAt = interruptAt; + break; + } + if (Exit.isFailure(secondExit)) { + assert.fail("the matching request fiber was interrupted"); + } + assert.strictEqual(secondExit.value, "invalidWindow"); + } + + assert.isUndefined( + orphanedAt, + `interruption left the next matching request pending at scheduler check ${orphanedAt}`, + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 224662e9dca7f..16a7478d954ef 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -7,7 +7,8 @@ * * Transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm - * scans only reparse files that changed. + * scans only reparse files that changed, and a file that merely grew resumes + * from its cached parse position so only the appended bytes are read. * * @module UsageService */ @@ -26,6 +27,7 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -272,7 +274,14 @@ export const make = Effect.gen(function* () { ); }); - /** Parses one transcript, reusing the cached result when it is unchanged. */ + /** + * Parses one transcript, reusing the cached result when it is unchanged. + * + * A file that only grew re-parses from the cached position, so an actively + * written multi-hundred-megabyte rollout costs its appended bytes per scan + * rather than a full re-read. The reader verifies the position's guard bytes + * and silently restarts from byte 0 when they no longer match. + */ const readFileRecords = ( filePath: string, size: number, @@ -289,23 +298,85 @@ export const make = Effect.gen(function* () { cached.mtimeMs === mtimeMs && cached.provider === provider ) { - return cached.records; + return cached.tailRecords.length === 0 + ? cached.records + : [...cached.records, ...cached.tailRecords]; } - const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // Only a strictly grown file may resume. Same size with a new mtime, or + // a shrunken file, means rewritten content; re-parse it whole. + const resumeFrom = + cached !== undefined && cached.provider === provider && size > cached.size + ? cached.position + : undefined; + + const parsed = yield* Effect.promise(() => + readTranscriptRecords(filePath, provider, resumeFrom), + ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. if (parsed === null) return []; - // Stored already de-duplicated within the file, which is 99% of all - // duplicates. The aggregator still runs the cross-file dedupe pass. - const records = dedupeWithinFile(parsed); - fileCache.set(filePath, { size, mtimeMs, provider, records }); + // Stored already de-duplicated within the file, which is 99% of all + // duplicates. The aggregator still runs the cross-file dedupe pass. One + // seen set spans the cached base, the new lines, and the tail so a + // resumed parse dedupes exactly like a full one. + const base = parsed.resumed && cached !== undefined ? cached.records : []; + const seen = new Set(); + const records = dedupeWithinFile([...base, ...parsed.records], seen); + const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + + fileCache.set(filePath, { + size, + mtimeMs, + provider, + records, + tailRecords, + position: parsed.position, + }); cacheDirty = true; - return records; + return tailRecords.length === 0 ? records : [...records, ...tailRecords]; }); - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + /** One provider directory's walk and parse, before rates are involved. */ + interface ScannedDir { + readonly provider: UsageProviderKind; + readonly dir: string; + readonly volumeId: string; + /** Parsed records per file, or `null` when the directory does not exist. */ + readonly files: + | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] + | null; + } + + const collectDirs = Effect.fn("UsageService.collectDirs")(function* (windowStartMs: number) { + // The home resolvers ask for `Path` themselves; satisfy them from the + // instance we already hold so the scan stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const scanned: ScannedDir[] = []; + for (const { provider, dir, fileName } of dirs) { + const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!exists) { + scanned.push({ provider, dir, volumeId, files: null }); + continue; + } + const files = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + ); + const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; + for (const file of files) { + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + parsedFiles.push({ path: file.path, records }); + } + scanned.push({ provider, dir, volumeId, files: parsedFiles }); + } + return scanned; + }); + + const scanSummary = Effect.fn("UsageService.scanSummary")(function* (input: UsageSummaryInput) { if (input.sinceDay > input.untilDay) { return yield* new UsageReadError({ reason: "invalidWindow", @@ -338,13 +409,9 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; - yield* ensureRates(); yield* ensureScanCacheLoaded; const hostId = NodeOS.hostname(); - // The home resolvers ask for `Path` themselves; satisfy them from the - // instance we already hold so `readSummary` stays context-free. - const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ @@ -355,6 +422,13 @@ export const make = Effect.gen(function* () { const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + // Pricing only matters once records are aggregated, so the rate table + // loads while transcripts stream instead of gating them: a cold rates + // fetch on a slow network no longer delays the scan by its own timeout. + const [, scannedDirs] = yield* Effect.all([ensureRates(), collectDirs(windowStartMs)], { + concurrency: 2, + }); + const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -368,13 +442,8 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir, fileName } of dirs) { - const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); - const exists = yield* fileSystem - .exists(dir) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - - if (!exists) { + for (const { provider, dir, volumeId, files } of scannedDirs) { + if (files === null) { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, status: "missing", @@ -388,9 +457,6 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => - listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), - ); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a @@ -399,13 +465,12 @@ export const make = Effect.gen(function* () { for (const file of files) { livePaths.add(file.path); - const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); - if (records.length === 0) { + if (file.records.length === 0) { skippedFiles += 1; continue; } scannedFiles += 1; - for (const record of records) { + for (const record of file.records) { // Only sessions that contributed in-window count: the mtime slack // admits boundary files whose records fall outside the range. if (aggregator.add(record) && record.sessionId.length > 0) { @@ -459,6 +524,52 @@ export const make = Effect.gen(function* () { } satisfies UsageSummary; }); + /** + * In-flight scans by window, so concurrent identical requests (the usage + * page open on two clients at once) share one scan instead of racing over + * the same corpus twice. + */ + const inflightScans = new Map>(); + + const scanKey = (input: UsageSummaryInput): string => + JSON.stringify([ + input.timeZone, + input.sinceDay, + input.untilDay, + input.resolution ?? "day", + input.sinceTime ?? null, + input.untilTime ?? null, + ]); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const key = scanKey(input); + const deferred = yield* Effect.uninterruptible( + Effect.gen(function* () { + const existing = inflightScans.get(key); + if (existing !== undefined) return existing; + + // Enrollment and detached-fiber creation must be atomic. Otherwise a + // canceled first caller can leave a Deferred with no scan to finish it. + const created = Deferred.makeUnsafe(); + inflightScans.set(key, created); + // Detached so one departing client cannot tear the scan out from under + // the fibers awaiting it; a finished scan warms the cache either way. + yield* scanSummary(input).pipe( + Effect.onExit((exit) => + Effect.sync(() => inflightScans.delete(key)).pipe( + Effect.andThen(Deferred.done(created, exit)), + ), + ), + Effect.forkDetach, + ); + return created; + }), + ); + // Waiting stays interruptible. The detached scan continues for other + // callers and still warms the cache if this caller leaves. + return yield* Deferred.await(deferred); + }); + return { readSummary } as const; }); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 8c6faa88a263f..fdb0aabafa403 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -5,6 +5,7 @@ import { dedupeWithinFile, encodeScanCache, pruneScanCache, + type CachedFile, type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -28,10 +29,27 @@ function record(overrides: Partial = {}): UsageRecord { }; } +function position(overrides: Partial = {}): CachedFile["position"] { + return { + resumeOffset: 120, + guardLength: 64, + guardHash: 0xdeadbeef, + codexState: null, + ...overrides, + }; +} + function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { const cache: ScanCache = new Map(); for (const [path, mtimeMs, records] of entries) { - cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + cache.set(path, { + size: records.length * 10, + mtimeMs, + provider: "claude", + records, + tailRecords: [], + position: position(), + }); } return cache; } @@ -49,14 +67,67 @@ describe("scan cache round trip", () => { records: [ record({ provider: "grok", model: "grok-4.5-build", dedupeKey: "s:p:grok-4.5-build" }), ], + tailRecords: [record({ provider: "grok", model: "grok-4.5-build", dedupeKey: null })], + position: position({ resumeOffset: 30, guardLength: 30, guardHash: 123 }), + }); + original.set("/codex.jsonl", { + size: 80, + mtimeMs: 400, + provider: "codex", + records: [record({ provider: "codex", model: "gpt-5.2-codex", dedupeKey: null })], + tailRecords: [], + position: position({ + codexState: { + model: "gpt-5.2-codex", + sessionId: "session-c", + lastUsageSignature: '{"input_tokens":1}', + sawSessionMeta: true, + suppressingForkCopies: false, + forkCopyAnchorMs: 0, + }, + }), }); const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); - expect(restored.size).toBe(3); + expect(restored.size).toBe(4); expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl")); + expect(restored.get("/codex.jsonl")).toEqual(original.get("/codex.jsonl")); + }); + + it("drops an entry whose persisted parse state is corrupt", () => { + // Resuming with a bad reducer state would attach appended usage to the + // wrong model or replay fork-copied history; that entry must cold parse. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { ...encoded.files["/a.jsonl"]!, cs: { model: 42 } }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it("drops an entry whose guard length is outside the supported range", () => { + // The guard length sizes a Buffer in the reader; a bogus value would make + // every parse of that file fail and silently drop its usage. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { + ...encoded, + files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it("rejects a document from the previous cache version", () => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const previous = { ...encoded, version: 2 }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); it("interns repeated model and session strings", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index bca97152c1d6b..102058a07d358 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -19,17 +19,28 @@ import * as NodePath from "node:path"; import type { UsageProviderKind } from "@t3tools/contracts"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; +import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // v2: Codex fork-copy suppression changed what a file parses to, so v1 // entries would keep serving double-counted records forever. -export const USAGE_SCAN_CACHE_VERSION = 2 as const; +// v3: entries carry the parse position and reducer state so a grown file +// re-parses only its appended bytes instead of starting over. +export const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile { readonly size: number; readonly mtimeMs: number; readonly provider: UsageProviderKind; + /** Records from newline-terminated lines, up to `position.resumeOffset`. */ readonly records: readonly UsageRecord[]; + /** + * Records from a trailing segment the writer had not newline-terminated at + * parse time. Kept apart from `records` because an incremental parse + * re-reads that segment and would otherwise double count it. + */ + readonly tailRecords: readonly UsageRecord[]; + readonly position: TranscriptParsePosition; } export type ScanCache = Map; @@ -57,6 +68,14 @@ interface SerializedFile { readonly m: number; readonly p: UsageProviderKind; readonly r: readonly SerializedRecord[]; + /** Tail records; see `CachedFile.tailRecords`. */ + readonly t: readonly SerializedRecord[]; + /** Parse position: resume offset, guard length, guard hash. */ + readonly o: number; + readonly gl: number; + readonly gh: number; + /** Codex reducer state at `o`; `null` for stateless providers. */ + readonly cs: CodexScanState | null; } interface SerializedCache { @@ -82,24 +101,31 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { return next; }; + const serializeRecord = (record: UsageRecord): SerializedRecord => [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + ]; + const files: Record = {}; for (const [path, entry] of cache) { files[path] = { s: entry.size, m: entry.mtimeMs, p: entry.provider, - r: entry.records.map((record) => [ - record.timestampMs, - intern(models, modelIndex, record.model), - intern(sessions, sessionIndex, record.sessionId), - record.totals.uncachedInputTokens, - record.totals.cachedInputTokens, - record.totals.cacheCreationTokens, - record.totals.outputTokens, - record.totals.reasoningTokens, - record.dedupeKey, - record.reportedCostUsd, - ]), + r: entry.records.map(serializeRecord), + t: entry.tailRecords.map(serializeRecord), + o: entry.position.resumeOffset, + gl: entry.position.guardLength, + gh: entry.position.guardHash, + cs: entry.position.codexState, }; } @@ -133,24 +159,16 @@ export function decodeScanCache(document: unknown): ScanCache { const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; - for (const [path, raw] of Object.entries(root.files)) { - if (typeof raw !== "object" || raw === null) continue; - const entry = raw as Partial; - if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; - if (!isRecordArray(entry.r)) continue; - - const provider: UsageProviderKind = entry.p; + // Any corrupt row disqualifies the whole entry. Keeping the survivors + // under the original (size, mtime) would read as a valid warm hit and the + // file would never be re-parsed, silently losing the dropped rows' usage. + const decodeRecords = ( + rows: readonly unknown[], + provider: UsageProviderKind, + ): UsageRecord[] | null => { const records: UsageRecord[] = []; - // Any corrupt row disqualifies the whole entry. Keeping the survivors - // under the original (size, mtime) would read as a valid warm hit and the - // file would never be re-parsed, silently losing the dropped rows' usage. - let corrupt = false; - for (const row of entry.r) { - if (!isRecordArray(row) || row.length < 10) { - corrupt = true; - break; - } + for (const row of rows) { + if (!isRecordArray(row) || row.length < 10) return null; const [ timestampMs, modelIndex, @@ -175,8 +193,7 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(output) || !Number.isFinite(reasoning) ) { - corrupt = true; - break; + return null; } records.push({ @@ -195,14 +212,89 @@ export function decodeScanCache(document: unknown): ScanCache { dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } + return records; + }; - if (corrupt) continue; - cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + for (const [path, raw] of Object.entries(root.files)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Partial; + if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; + if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; + // Position fields feed byte offsets and a Buffer allocation in the reader, + // so anything outside their real ranges must reject the entry: a bogus + // guard length would otherwise fail every parse of the file, silently + // dropping its usage instead of costing the documented cold re-parse. + if ( + typeof entry.o !== "number" || + !Number.isSafeInteger(entry.o) || + entry.o < 0 || + typeof entry.gl !== "number" || + !Number.isSafeInteger(entry.gl) || + entry.gl < 0 || + entry.gl > GUARD_LENGTH || + entry.gl > entry.o || + typeof entry.gh !== "number" || + !Number.isFinite(entry.gh) + ) { + continue; + } + const codexState = decodeCodexState(entry.cs); + if (codexState === undefined) continue; + + const provider: UsageProviderKind = entry.p; + const records = decodeRecords(entry.r, provider); + const tailRecords = decodeRecords(entry.t, provider); + if (records === null || tailRecords === null) continue; + + cache.set(path, { + size: entry.s, + mtimeMs: entry.m, + provider, + records, + tailRecords, + position: { + resumeOffset: entry.o, + guardLength: entry.gl, + guardHash: entry.gh, + codexState, + }, + }); } return cache; } +/** + * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt + * value, which disqualifies the entry: resuming with a bad state would attach + * appended usage to the wrong model or replay fork-copied history. + */ +function decodeCodexState(value: unknown): CodexScanState | null | undefined { + if (value === null) return null; + if (typeof value !== "object") return undefined; + const state = value as Partial; + if ( + typeof state.model !== "string" || + typeof state.sessionId !== "string" || + (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || + typeof state.sawSessionMeta !== "boolean" || + typeof state.suppressingForkCopies !== "boolean" || + typeof state.forkCopyAnchorMs !== "number" || + !Number.isFinite(state.forkCopyAnchorMs) + ) { + return undefined; + } + return { + model: state.model, + sessionId: state.sessionId, + lastUsageSignature: state.lastUsageSignature ?? null, + sawSessionMeta: state.sawSessionMeta, + suppressingForkCopies: state.suppressingForkCopies, + forkCopyAnchorMs: state.forkCopyAnchorMs, + }; +} + export interface PruneOptions { /** Files the walk just saw. Only meaningful inside the walked window. */ readonly livePaths: ReadonlySet; @@ -251,9 +343,17 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number return removed; } -/** Within-file de-duplication, applied before an entry is cached. */ -export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { - const seen = new Set(); +/** + * Within-file de-duplication, applied before an entry is cached. + * + * Callers stitching an incremental parse together pass one `seen` set across + * the line and tail record batches so the whole file stays deduplicated as a + * unit; the set is mutated in place. + */ +export function dedupeWithinFile( + records: readonly UsageRecord[], + seen: Set = new Set(), +): readonly UsageRecord[] { const kept: UsageRecord[] = []; for (const record of records) { if (record.dedupeKey !== null) { diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts new file mode 100644 index 0000000000000..5feb68b2ff58c --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -0,0 +1,210 @@ +// @effect-diagnostics nodeBuiltinImport:off - resume coverage writes, appends +// to, and truncates real transcript files byte-exactly, mirroring the reader's +// own deliberate node:fs usage. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; + +import { readTranscriptRecords } from "./usageTranscriptReader.ts"; + +let dir: string; + +beforeEach(async () => { + dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); +}); + +afterEach(async () => { + await NodeFSP.rm(dir, { recursive: true, force: true }); +}); + +function claudeLine(id: number, outputTokens: number): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: outputTokens }, + }, + })}\n`; +} + +function codexMetaLine(): string { + return `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T10:00:00Z", + payload: { type: "session_meta", id: "codex-session-1" }, + })}\n`; +} + +function codexModelLine(model: string): string { + return `${JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T10:00:01Z", + payload: { type: "turn_context", model }, + })}\n`; +} + +function codexUsageLine(outputTokens: number, secondsOffset: number): string { + return `${JSON.stringify({ + type: "event_msg", + timestamp: `2026-08-01T10:00:${String(secondsOffset).padStart(2, "0")}Z`, + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 100, output_tokens: outputTokens } }, + }, + })}\n`; +} + +describe("readTranscriptRecords resume", () => { + it("parses only appended lines when resuming a grown file", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 2); + assert.isFalse(first.resumed); + + await NodeFSP.appendFile(path, claudeLine(3, 11)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.strictEqual(second.records.length, 1); + assert.strictEqual(second.records[0]?.totals.outputTokens, 11); + + // The stitched result matches a from-scratch parse of the whole file. + const full = await readTranscriptRecords(path, "claude"); + assert.isNotNull(full); + assert.deepStrictEqual([...first.records, ...second.records], [...full.records]); + }); + + it("carries the Codex reducer state across the resume boundary", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile(path, codexMetaLine() + codexModelLine("gpt-5.2-codex")); + const first = await readTranscriptRecords(path, "codex"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 0); + + // The appended usage event has no turn_context or session_meta of its own; + // model and session must come from the state captured before the boundary. + await NodeFSP.appendFile(path, codexUsageLine(9, 5)); + const second = await readTranscriptRecords(path, "codex", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.strictEqual(second.records.length, 1); + assert.strictEqual(second.records[0]?.model, "gpt-5.2-codex"); + assert.strictEqual(second.records[0]?.sessionId, "codex-session-1"); + }); + + it("suppresses a Codex duplicate usage event that straddles the boundary", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile( + path, + codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5), + ); + const first = await readTranscriptRecords(path, "codex"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 1); + + // Codex re-emits an unchanged token_count on stream boundaries; the copy + // lands after the resume point and must still be dropped. + await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8)); + const second = await readTranscriptRecords(path, "codex", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [21], + ); + }); + + it("defers an unterminated trailing line to tailRecords, then consumes it once terminated", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + const unterminated = claudeLine(2, 7).trimEnd(); + await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 1); + assert.strictEqual(first.tailRecords.length, 1); + assert.strictEqual(first.tailRecords[0]?.totals.outputTokens, 7); + + // Completing the line and appending another re-reads from the resume + // point, so the once-tail record arrives exactly once as a line record. + await NodeFSP.appendFile(path, `\n${claudeLine(3, 11)}`); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [7, 11], + ); + assert.strictEqual(second.tailRecords.length, 0); + }); + + it("re-parses from the start when the guard bytes no longer match", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + + // Same path, larger size, different content: a replaced file, not growth. + await NodeFSP.writeFile(path, claudeLine(4, 13) + claudeLine(5, 17)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isFalse(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [13, 17], + ); + }); + + it("re-parses from the start when the file shrank below the resume point", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + + await NodeFSP.writeFile(path, claudeLine(3, 11)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isFalse(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [11], + ); + }); + + it("parses a line larger than one stream chunk", async () => { + // Tool-heavy transcripts carry multi-megabyte single lines; they arrive + // split across many chunks and must reassemble into one record. + const path = NodePath.join(dir, "claude.jsonl"); + const bigLine = `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: "req_big", + sessionId: "session-1", + padding: "x".repeat(512 * 1024), + message: { + id: "msg_big", + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: 42 }, + }, + })}\n`; + await NodeFSP.writeFile(path, bigLine + claudeLine(2, 7)); + + const parsed = await readTranscriptRecords(path, "claude"); + assert.isNotNull(parsed); + assert.deepStrictEqual( + parsed.records.map((record) => record.totals.outputTokens), + [42, 7], + ); + }); + + it("returns null for an unreadable file", async () => { + assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 33aef8fae25cd..9e5ab6e0c9e0a 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -4,16 +4,19 @@ * * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB - * across ~1,500 files, and `readline` over a read stream is roughly an order of + * across ~1,500 files, and buffer-level streaming is roughly an order of * magnitude cheaper than materialising each file. The equivalent Effect stream * pipeline is idiomatic but not fast enough to sit behind a page load. * + * Transcripts are append-only, so a parse also reports the byte position it + * stopped at. A later scan of the same file resumes from that position and + * parses only the appended bytes, which is what keeps a warm scan cheap while a + * session is actively writing a multi-hundred-megabyte rollout. + * * @module usageTranscriptReader */ -import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; -import * as NodeReadline from "node:readline"; import type { UsageProviderKind } from "@t3tools/contracts"; @@ -23,6 +26,7 @@ import { parseClaudeLine, parseCodexLine, parseGrokLine, + type CodexScanState, type UsageRecord, } from "./usageTranscripts.ts"; @@ -32,6 +36,56 @@ export interface TranscriptFile { readonly mtimeMs: number; } +/** + * Where a parse stopped, with enough state to continue from there. + * + * The guard hash fingerprints the bytes immediately before `resumeOffset`. A + * resume only proceeds when those bytes still match: transcripts are + * append-only by design, but a rotated or rewritten file silently mis-parsed + * from the middle would corrupt usage totals. The window is a cheap tripwire + * for those realistic failure shapes, all of which disturb the file's tail at + * that exact offset; it deliberately does not hash the whole prefix, which + * would cost the full re-read the resume exists to avoid. + */ +export interface TranscriptParsePosition { + /** Byte offset just past the last newline-terminated line consumed. */ + readonly resumeOffset: number; + /** Length of the fingerprinted window ending at `resumeOffset`. */ + readonly guardLength: number; + /** FNV-1a hash of that window. */ + readonly guardHash: number; + /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */ + readonly codexState: CodexScanState | null; +} + +export interface TranscriptParseResult { + /** Records from newline-terminated lines at or after the parse start. */ + readonly records: readonly UsageRecord[]; + /** + * Records from a trailing segment the writer has not newline-terminated yet. + * Kept out of `records` because `position` deliberately excludes that + * segment: the next scan re-reads it once the writer finishes the line. + */ + readonly tailRecords: readonly UsageRecord[]; + readonly position: TranscriptParsePosition; + /** Whether the parse continued from `resumeFrom` rather than byte 0. */ + readonly resumed: boolean; +} + +/** 64 bytes of JSONL tail is ample to distinguish a replaced file. */ +export const GUARD_LENGTH = 64; +const NEWLINE = 0x0a; +const CARRIAGE_RETURN = 0x0d; + +function fnv1a(buffer: Buffer): number { + let hash = 0x811c9dc5; + for (let index = 0; index < buffer.length; index += 1) { + hash ^= buffer[index]!; + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + /** * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. * @@ -100,6 +154,25 @@ export async function readDirectoryVolumeId(path: string): Promise { } } +async function guardMatches( + handle: NodeFSP.FileHandle, + position: TranscriptParsePosition, +): Promise { + if (position.guardLength <= 0 || position.guardLength > GUARD_LENGTH) return false; + try { + const window = Buffer.alloc(position.guardLength); + const { bytesRead } = await handle.read( + window, + 0, + position.guardLength, + position.resumeOffset - position.guardLength, + ); + return bytesRead === position.guardLength && fnv1a(window) === position.guardHash; + } catch { + return false; + } +} + /** * Streams one transcript and returns the usage records it contains, or `null` * when the file could not be read. @@ -109,6 +182,10 @@ export async function readDirectoryVolumeId(path: string): Promise { * under the same `(size, mtime)` key would silently drop that file's usage * until the file next changes. * + * With `resumeFrom`, parsing continues from that position when its guard bytes + * still match, so only appended lines are read; otherwise the whole file is + * re-parsed from the start and `resumed` reports `false`. + * * Codex carries the active model on `turn_context` lines that hold no usage of * their own, so those still have to pass through the reducer to keep model * attribution correct. @@ -116,43 +193,121 @@ export async function readDirectoryVolumeId(path: string): Promise { export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, -): Promise { - const records: UsageRecord[] = []; - const codexState = initialCodexScanState(); + resumeFrom?: TranscriptParsePosition, +): Promise { + let handle: NodeFSP.FileHandle; + try { + handle = await NodeFSP.open(filePath, "r"); + } catch { + return null; + } try { - const lines = NodeReadline.createInterface({ - input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); + let codexState = initialCodexScanState(); + let resumed = false; + let start = 0; + if ( + resumeFrom !== undefined && + resumeFrom.resumeOffset > 0 && + (provider !== "codex" || resumeFrom.codexState !== null) && + (await guardMatches(handle, resumeFrom)) + ) { + if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState }; + start = resumeFrom.resumeOffset; + resumed = true; + } - for await (const line of lines) { + const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { if (provider === "codex") { if ( !mightCarryUsage(line, provider) && !line.includes('"turn_context"') && !line.includes('"session_meta"') ) { - continue; + return; } - const record = parseCodexLine(line, codexState); - if (record !== null) records.push(record); - continue; + const record = parseCodexLine(line, state); + if (record !== null) out.push(record); + return; } - + if (!mightCarryUsage(line, provider)) return; if (provider === "grok") { - if (!mightCarryUsage(line, provider)) continue; - for (const grokRecord of parseGrokLine(line)) records.push(grokRecord); + for (const grokRecord of parseGrokLine(line)) out.push(grokRecord); + return; + } + const record = parseClaudeLine(line); + if (record !== null) out.push(record); + }; + + const toLineString = (lineBuffer: Buffer): string => { + const content = + lineBuffer.length > 0 && lineBuffer[lineBuffer.length - 1] === CARRIAGE_RETURN + ? lineBuffer.subarray(0, -1) + : lineBuffer; + return content.toString("utf8"); + }; + + const records: UsageRecord[] = []; + // Buffer-level line splitting rather than `readline`, because resuming + // needs byte-exact offsets and decoded strings cannot provide them. + // Newline-free chunks are collected rather than concatenated as they + // arrive, so a single huge line costs one copy instead of one per chunk. + let resumeOffset = start; + let pendingChunks: Buffer[] = []; + const stream = handle.createReadStream({ + start, + autoClose: false, + }) as AsyncIterable; + for await (const chunk of stream) { + if (!chunk.includes(NEWLINE)) { + pendingChunks.push(chunk); continue; } + const buffer: Buffer = + pendingChunks.length === 0 ? chunk : Buffer.concat([...pendingChunks, chunk]); + pendingChunks = []; + let lineStart = 0; + for (;;) { + const newlineIndex = buffer.indexOf(NEWLINE, lineStart); + if (newlineIndex === -1) break; + parseLine(toLineString(buffer.subarray(lineStart, newlineIndex)), codexState, records); + lineStart = newlineIndex + 1; + } + resumeOffset += lineStart; + if (lineStart < buffer.length) pendingChunks.push(buffer.subarray(lineStart)); + } - if (!mightCarryUsage(line, provider)) continue; - const record = parseClaudeLine(line); - if (record !== null) records.push(record); + // A trailing segment without its newline is parsed for this result but not + // consumed: a writer may still be appending to it, and counting a half + // record now and its full form later would double count. + const tailRecords: UsageRecord[] = []; + if (pendingChunks.length > 0) { + const pending = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks); + if (pending.length > 0) parseLine(toLineString(pending), { ...codexState }, tailRecords); } + + const guardLength = Math.min(GUARD_LENGTH, resumeOffset); + let guardHash = 0; + if (guardLength > 0) { + const window = Buffer.alloc(guardLength); + await handle.read(window, 0, guardLength, resumeOffset - guardLength); + guardHash = fnv1a(window); + } + + return { + records, + tailRecords, + position: { + resumeOffset, + guardLength, + guardHash, + codexState: provider === "codex" ? codexState : null, + }, + resumed, + }; } catch { return null; + } finally { + await handle.close().catch(() => undefined); } - - return records; } From 7e4ce3bbb16c3cfa1ea756de917d8f9398e5999e Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 1 Sep 2026 12:11:41 +0200 Subject: [PATCH 013/136] perf(server): cut chatty tool-update frames by 90% (#8368) --- .../ActivityPayloadProjection.ts | 7 +- .../ThreadLiveEventCoalescer.test.ts | 171 +++++++++++++ .../orchestration/ThreadLiveEventCoalescer.ts | 207 +++++++++++++++ apps/server/src/server.test.ts | 240 ++++++++++++++++++ apps/server/src/ws.ts | 23 +- 5 files changed, 633 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts create mode 100644 apps/server/src/orchestration/ThreadLiveEventCoalescer.ts diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 7d2ba79ce4ad5..0b1cb15d3dbc3 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -495,9 +495,6 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | * update within the turn — a later update belongs to a subsequent call that * reuses the same identity and is still in flight. Rows without a lifecycle * identity pass through, matching the clients, which never collapse them. - * Live `thread.activity-appended` events are untouched: updates still stream - * in real time and the completion supersedes them on the client as before. - * * Deliberate divergence from client collapse: clients fold only *adjacent* * lifecycle rows, so a superseded update separated from its completion by an * interleaved parallel call renders as its own row today, and this drop @@ -524,7 +521,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { continue; } - const key = `${activity.turnId ?? ""}${identity}`; + const key = `${activity.turnId ?? ""}\u0000${identity}`; const indices = completionIndicesByKey.get(key); if (indices) { indices.push(index); @@ -544,7 +541,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { return true; } - const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}${identity}`); + const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}\u0000${identity}`); return !indices?.some((completionIndex) => completionIndex > index); }); } diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts new file mode 100644 index 0000000000000..0a9915294d032 --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts @@ -0,0 +1,171 @@ +import { + EventId, + MessageId, + ThreadId, + TurnId, + type OrchestrationEvent, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; +import { describe, expect } from "vite-plus/test"; + +import { + coalesceLiveToolUpdatedEvents, + makeThreadLiveEventCoalescer, +} from "./ThreadLiveEventCoalescer.ts"; + +const threadId = ThreadId.make("thread-coalescer-test"); +const turnId = TurnId.make("turn-coalescer-test"); + +function makeToolActivity( + sequence: number, + options: { + readonly kind?: "tool.updated" | "tool.completed"; + readonly toolCallId?: string; + readonly turnId?: TurnId; + } = {}, +): OrchestrationEvent { + const { + kind = "tool.updated", + toolCallId = "call-edit", + turnId: activityTurnId = turnId, + } = options; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`activity-${sequence}`), + tone: "tool", + kind, + summary: "Editing app.ts", + payload: { + itemType: "file_change", + title: "Editing app.ts", + data: toolCallId ? { toolCallId } : {}, + }, + turnId: activityTurnId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + return { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { threadId, activity }, + }; +} + +function makeMessage(sequence: number): OrchestrationEvent { + return { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId, + messageId: MessageId.make(`message-${sequence}`), + role: "assistant", + text: "Still working", + turnId, + streaming: false, + createdAt: "2026-01-01T00:00:02.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }; +} + +describe("ThreadLiveEventCoalescer", () => { + it("coalesces only calls with a stable toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "call-a" }), + makeToolActivity(2, { toolCallId: "call-b" }), + makeToolActivity(3, { toolCallId: "call-a" }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3]); + }); + + it("preserves parallel same-label calls without a stable toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "" }), + makeToolActivity(2, { toolCallId: "" }), + makeToolActivity(3, { kind: "tool.completed", toolCallId: "" }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2, 3]); + }); + + it("does not coalesce stable tool calls across turns", () => { + const events = [ + makeToolActivity(1, { turnId: TurnId.make("turn-old") }), + makeToolActivity(2, { turnId: TurnId.make("turn-new") }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2]); + }); + + it("flushes a stable update run before a completion boundary", () => { + const events = [ + makeToolActivity(1), + makeToolActivity(2), + makeToolActivity(3, { kind: "tool.completed" }), + makeToolActivity(4), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3, 4]); + }); + + it.effect("flushes pending tool updates as soon as an unrelated event arrives", () => + Effect.scoped( + Effect.gen(function* () { + const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); + const startedAt = yield* Clock.currentTimeMillis; + yield* Effect.forEach( + Array.from({ length: 10 }, (_, index) => index + 2), + (sequence) => + coalescer.offerAndWait({ kind: "event", event: makeToolActivity(sequence) }), + { discard: true }, + ); + yield* coalescer.offerAndWait({ kind: "event", event: makeMessage(12) }); + + expect(yield* Clock.currentTimeMillis).toBe(startedAt); + expect( + Array.from(yield* coalescer.takeAll).map((item) => + item.kind === "event" ? item.event.sequence : item.kind, + ), + ).toEqual([11, 12]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("flushes pending tool updates as soon as a synchronization marker arrives", () => + Effect.scoped( + Effect.gen(function* () { + const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); + const startedAt = yield* Clock.currentTimeMillis; + yield* coalescer.offerAndWait({ kind: "event", event: makeToolActivity(2) }); + yield* coalescer.offerAndWait({ kind: "event", event: makeToolActivity(3) }); + yield* coalescer.offerAndWait({ kind: "synchronized" }); + + expect(yield* Clock.currentTimeMillis).toBe(startedAt); + expect( + Array.from(yield* coalescer.takeAll).map((item) => + item.kind === "event" ? item.event.sequence : item.kind, + ), + ).toEqual([3, "synchronized"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts new file mode 100644 index 0000000000000..8271f6a550fb7 --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts @@ -0,0 +1,207 @@ +import type { OrchestrationEvent, OrchestrationThreadStreamItem } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { projectActivityEvent } from "./ActivityPayloadProjection.ts"; + +const COALESCE_WINDOW = Duration.millis(50); +const MAX_PENDING_UPDATES = 512; + +export type ThreadLiveInput = + | { readonly kind: "event"; readonly event: OrchestrationEvent } + | { readonly kind: "synchronized" }; + +function isToolUpdated(event: OrchestrationEvent): boolean { + return ( + event.type === "thread.activity-appended" && event.payload.activity.kind === "tool.updated" + ); +} + +function asTrimmedString(value: unknown): string | null { + if (!Predicate.isString(value)) { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function stableToolCallIdentity(event: OrchestrationEvent): string | null { + if (event.type !== "thread.activity-appended") { + return null; + } + const payload = event.payload.activity.payload; + if (!Predicate.isObject(payload)) { + return null; + } + const data = Predicate.isObject(payload.data) ? payload.data : null; + return asTrimmedString(payload.toolCallId) ?? asTrimmedString(data?.toolCallId); +} + +/** + * Retain only the latest in-flight update for each stable tool-call id in a + * live run. Anonymous calls pass through because labels are not unique when + * tools execute in parallel. Survivors remain in sequence order. + */ +export function coalesceLiveToolUpdatedEvents( + events: ReadonlyArray, +): ReadonlyArray { + const survivors: Array = []; + let pendingUpdates: Array = []; + + const flushUpdates = () => { + const seen = new Set(); + const latestUpdates: Array = []; + for (let index = pendingUpdates.length - 1; index >= 0; index -= 1) { + const event = pendingUpdates[index]!; + const identity = stableToolCallIdentity(event); + const activity = + event.type === "thread.activity-appended" ? event.payload.activity : undefined; + const key = identity ? `${activity?.turnId ?? ""}\u0000${identity}` : null; + if (key && seen.has(key)) { + continue; + } + if (key) { + seen.add(key); + } + latestUpdates.push(event); + } + latestUpdates.reverse(); + survivors.push(...latestUpdates); + pendingUpdates = []; + }; + + for (const event of events) { + if (isToolUpdated(event)) { + pendingUpdates.push(event); + continue; + } + flushUpdates(); + survivors.push(event); + } + flushUpdates(); + return survivors; +} + +export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoalescer")( + function* (options?: { readonly coalesceWindow?: Duration.Input }) { + const output = yield* Queue.unbounded(); + const input = yield* Queue.unbounded<{ + readonly value: ThreadLiveInput; + readonly processed?: Deferred.Deferred; + }>(); + const mutex = yield* Semaphore.make(1); + const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW; + let pendingUpdates: Array = []; + let windowGeneration = 0; + let windowFiber: Fiber.Fiber | null = null; + + const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () { + const fiber = windowFiber; + if (!fiber) { + return; + } + windowFiber = null; + yield* Fiber.interrupt(fiber); + }); + + const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* ( + boundary?: OrchestrationEvent, + ) { + const events = boundary ? [...pendingUpdates, boundary] : pendingUpdates; + pendingUpdates = []; + if (events.length === 0) { + return; + } + yield* Queue.offerAll( + output, + coalesceLiveToolUpdatedEvents(events).map((event) => ({ + kind: "event" as const, + event: projectActivityEvent(event), + })), + ); + }); + + const flushWindow = (generation: number) => + Effect.sleep(coalesceWindow).pipe( + Effect.andThen( + mutex.withPermits(1)( + Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)), + ), + ), + Effect.ensuring( + Effect.sync(() => { + if (generation === windowGeneration) { + windowFiber = null; + } + }), + ), + ); + + const process = Effect.fn("ThreadLiveEventCoalescer.process")(function* ( + input: ThreadLiveInput, + ) { + yield* mutex.withPermits(1)( + Effect.gen(function* () { + if (input.kind === "event" && isToolUpdated(input.event)) { + pendingUpdates.push(input.event); + if (pendingUpdates.length === 1) { + const generation = ++windowGeneration; + windowFiber = yield* Effect.forkScoped(flushWindow(generation)); + } + if (pendingUpdates.length >= MAX_PENDING_UPDATES) { + yield* cancelWindow(); + windowGeneration += 1; + yield* flushPending(); + } + return; + } + + yield* cancelWindow(); + windowGeneration += 1; + // A non-update event closes the run immediately. The coalescer keeps + // that boundary after the final update from the run. + if (input.kind === "event") { + yield* flushPending(input.event); + } else { + yield* flushPending(); + yield* Queue.offer(output, { kind: "synchronized" }); + } + }), + ); + }); + + yield* Stream.fromQueue(input).pipe( + Stream.runForEach(({ value, processed }) => + process(value).pipe( + Effect.andThen(processed ? Deferred.succeed(processed, undefined) : Effect.void), + ), + ), + Effect.forkScoped, + ); + + const offer = (value: ThreadLiveInput) => Queue.offer(input, { value }).pipe(Effect.asVoid); + + // Synchronization callers wait for their marker to pass through the same + // ordered input queue before draining output produced ahead of it. + const offerAndWait = Effect.fn("ThreadLiveEventCoalescer.offerAndWait")(function* ( + value: ThreadLiveInput, + ) { + const processed = yield* Deferred.make(); + yield* Queue.offer(input, { value, processed }); + yield* Deferred.await(processed); + }); + + return { + offer, + offerAndWait, + stream: Stream.fromQueue(output), + takeAll: Queue.takeAll(output), + } as const; + }, +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0780af4d1b4aa..93a3977ddd0f8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -19,6 +19,7 @@ import { ExternalLauncherCommandNotFoundError, OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, + type OrchestrationThreadActivity, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -30,6 +31,7 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + TurnId, WS_METHODS, WsRpcGroup, EditorId, @@ -197,6 +199,44 @@ const defaultModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", } as const; + +const makeLiveToolActivityEvent = ( + sequence: number, + kind: "tool.updated" | "tool.completed" = "tool.updated", + options: { + readonly toolCallId?: string; + readonly title?: string; + readonly path?: string; + } = {}, +): Extract => { + const { toolCallId = "call-edit", title = "Editing app.ts", path = "src/app.ts" } = options; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`activity-${sequence}`), + tone: "tool", + kind, + summary: title, + payload: { + itemType: "file_change", + title, + data: { toolCallId, path }, + }, + turnId: TurnId.make("turn-edit"), + createdAt: "2026-01-01T00:00:01.000Z", + }; + return { + sequence, + eventId: EventId.make(`event-tool-${sequence}`), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { threadId: defaultThreadId, activity }, + }; +}; const testEnvironmentDescriptor = { environmentId: EnvironmentId.make("environment-test"), label: "Test environment", @@ -6918,6 +6958,206 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + it.effect("coalesces buffered live tool updates to the latest state", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + makeLiveToolActivityEvent(2), + makeLiveToolActivityEvent(3), + makeLiveToolActivityEvent(4), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "event"); + assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 4); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("flushes more than one tool chunk before the synchronization marker", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + ...Array.from({ length: 512 }, (_, index) => + makeLiveToolActivityEvent(index + 2), + ), + makeLiveToolActivityEvent(514, "tool.updated", { + toolCallId: "call-read", + title: "Reading server.test.ts", + path: "apps/server/src/server.test.ts", + }), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + requestCompletionMarker: true, + }).pipe(Stream.take(4), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual( + items.slice(1, 3).map((item) => { + assert.equal(item?.kind, "event"); + if (item?.kind !== "event" || item.event.type !== "thread.activity-appended") { + return null; + } + return { + sequence: item.event.sequence, + summary: item.event.payload.activity.summary, + payload: item.event.payload.activity.payload, + }; + }), + [ + { + sequence: 513, + summary: "Editing app.ts", + payload: { + itemType: "file_change", + title: "Editing app.ts", + data: { + files: [{ path: "src/app.ts" }], + toolCallId: "call-edit", + }, + }, + }, + { + sequence: 514, + summary: "Reading server.test.ts", + payload: { + itemType: "file_change", + title: "Reading server.test.ts", + data: { + files: [{ path: "apps/server/src/server.test.ts" }], + toolCallId: "call-read", + }, + }, + }, + ], + ); + assert.deepEqual(items[3], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("flushes a tool update before an interleaved message", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + const messageEvent = { + sequence: 3, + eventId: EventId.make("event-interleaved-message"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: defaultThreadId, + messageId: MessageId.make("message-interleaved"), + role: "assistant", + text: "Still working", + turnId: TurnId.make("turn-edit"), + streaming: false, + createdAt: "2026-01-01T00:00:02.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + makeLiveToolActivityEvent(2), + messageEvent, + makeLiveToolActivityEvent(4, "tool.completed"), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(4), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual( + items + .slice(1) + .map((item) => (item.kind === "event" ? [item.event.sequence, item.event.type] : null)), + [ + [2, "thread.activity-appended"], + [3, "thread.message-sent"], + [4, "thread.activity-appended"], + ], + ); + assert.equal( + items[3]?.kind === "event" && items[3].event.type === "thread.activity-appended" + ? items[3].event.payload.activity.kind + : null, + "tool.completed", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("subscribeThread sends a fresh snapshot instead of replaying a large gap", () => Effect.gen(function* () { let readEventsCalls = 0; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9983de224f298..4da8420799e99 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -80,6 +80,7 @@ import { projectActivityEvent, projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; +import { makeThreadLiveEventCoalescer } from "./orchestration/ThreadLiveEventCoalescer.ts"; import { cleanupFailedUploadedAttachments, normalizeDispatchCommand, @@ -1501,17 +1502,15 @@ const makeWsRpcLayer = ( Stream.filter(isThisThreadDetailEvent), Stream.map((event) => ({ kind: "event" as const, - event: projectActivityEvent(event), + event, })), ); // Attach live delivery before reading either replay or snapshot state. // Otherwise an event published while the snapshot is loading is lost. - const liveBuffer = yield* Queue.unbounded(); - yield* Effect.forkScoped( - liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), - ); - const bufferedLiveStream = Stream.fromQueue(liveBuffer); + const liveBuffer = yield* makeThreadLiveEventCoalescer(); + yield* Effect.forkScoped(liveStream.pipe(Stream.runForEach(liveBuffer.offer))); + const bufferedLiveStream = liveBuffer.stream; // When the client already loaded the snapshot over HTTP it passes // that snapshot's sequence, and we resume the live subscription by @@ -1560,8 +1559,10 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + liveBuffer + .offerAndWait({ kind: "synchronized" as const }) + .pipe(Effect.andThen(liveBuffer.takeAll)), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; @@ -1602,8 +1603,10 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + liveBuffer + .offerAndWait({ kind: "synchronized" as const }) + .pipe(Effect.andThen(liveBuffer.takeAll)), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; From f32f9a2f41342bf8a1a109d6ebe9b70044c5311b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 03:24:24 -0700 Subject: [PATCH 014/136] fix(server): settle threads server-side (#8600) --- .../settings/DesktopClientSettings.test.ts | 2 - apps/mobile/src/features/home/HomeScreen.tsx | 47 +- .../src/features/home/useThreadListActions.ts | 12 +- .../features/settings/SettingsRouteScreen.tsx | 53 +- .../threads/ThreadNavigationSidebar.tsx | 51 +- .../features/threads/thread-list-v2-items.tsx | 26 +- .../src/features/threads/threadListV2.test.ts | 161 +---- .../src/features/threads/threadListV2.ts | 81 +-- .../src/persistence/mobile-preferences.ts | 5 - .../OrchestrationEngineHarness.integration.ts | 7 + .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/git/GitManager.test.ts | 425 +++++++++++- apps/server/src/git/GitManager.ts | 197 +++++- apps/server/src/orchestration/Errors.ts | 21 +- .../Layers/OrchestrationEngine.test.ts | 216 +++++- .../Layers/OrchestrationEngine.ts | 32 +- .../Layers/OrchestrationReactor.test.ts | 13 +- .../Layers/OrchestrationReactor.ts | 3 + .../Layers/ProviderCommandReactor.test.ts | 45 ++ .../Layers/ProviderCommandReactor.ts | 24 +- .../ThreadSettlementPolicy.test.ts | 159 +++++ .../orchestration/ThreadSettlementPolicy.ts | 108 +++ .../ThreadSettlementReactor.test.ts | 640 ++++++++++++++++++ .../orchestration/ThreadSettlementReactor.ts | 185 +++++ .../src/orchestration/decider.settled.test.ts | 51 +- apps/server/src/orchestration/decider.ts | 125 ++-- .../Layers/OrchestrationEventStore.ts | 23 +- .../Services/OrchestrationEventStore.ts | 5 +- apps/server/src/server.test.ts | 61 +- apps/server/src/server.ts | 21 +- apps/server/src/serverSettings.test.ts | 28 + apps/server/src/ws.ts | 67 +- apps/web/src/components/ChatView.tsx | 61 +- apps/web/src/components/Sidebar.logic.ts | 7 +- apps/web/src/components/Sidebar.tsx | 55 +- .../components/ThreadStatusIndicators.test.ts | 36 +- apps/web/src/components/chat/ChatHeader.tsx | 5 - .../components/settings/SettingsPanels.tsx | 133 ++-- .../settings/settingsSearch.test.ts | 23 +- .../src/components/settings/settingsSearch.ts | 8 +- .../useAvailableSettingsSearchItems.ts | 14 +- apps/web/src/hooks/useNowMinute.ts | 7 +- apps/web/src/hooks/useSettings.test.ts | 18 + apps/web/src/hooks/useSettings.ts | 4 +- apps/web/src/hooks/useThreadActionMenu.ts | 28 +- apps/web/src/hooks/useThreadActions.ts | 27 +- docs/internals/overview.md | 18 +- docs/user/thread-sidebar.md | 11 + .../src/state/threadSettled.test.ts | 587 ---------------- .../client-runtime/src/state/threadSettled.ts | 193 +----- .../src/state/threadSnoozed.test.ts | 60 ++ packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestration.ts | 8 + packages/contracts/src/settings.test.ts | 39 +- packages/contracts/src/settings.ts | 12 +- 55 files changed, 2593 insertions(+), 1658 deletions(-) create mode 100644 apps/server/src/orchestration/ThreadSettlementPolicy.test.ts create mode 100644 apps/server/src/orchestration/ThreadSettlementPolicy.ts create mode 100644 apps/server/src/orchestration/ThreadSettlementReactor.test.ts create mode 100644 apps/server/src/orchestration/ThreadSettlementReactor.ts delete mode 100644 packages/client-runtime/src/state/threadSettled.test.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 736311eb464bf..27981a8b98288 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -40,8 +40,6 @@ const clientSettings: ClientSettings = { planModeEnabled: false, showSkillsInSlashMenu: false, providerModelPreferences: {}, - sidebarAutoSettleAfterDays: 3, - sidebarAutoSettleOnMerge: true, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index b738ff6dd12f7..34f4f4057a5df 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -53,7 +53,6 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; @@ -209,9 +208,6 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -487,33 +483,6 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule, matching web. - const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && - (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); const handleSettleThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onSettleThread(thread); @@ -580,9 +549,7 @@ export function HomeScreen(props: HomeScreenProps) { toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // now is quantized to the minute and ticks so the inactivity auto-settle - // boundary is actually crossed while the app stays open (mirrors web); - // without a clock dependency the partition memoizes a frozen "now". + // The queued-start and snooze helpers need a clock while the list stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -591,8 +558,7 @@ export function HomeScreen(props: HomeScreenProps) { useFocusEffect( useCallback(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable or focus: the previous value can be hours - // old and misclassify the inactivity auto-settle boundary until the first tick. + // Refresh immediately on enable or focus because the previous value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -679,20 +645,15 @@ export function HomeScreen(props: HomeScreenProps) { projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, - now: `${nowMinute}:00.000Z`, - snoozeNow: new Date().toISOString(), + now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -864,7 +825,6 @@ export function HomeScreen(props: HomeScreenProps) { onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} onMovePinnedThread={handleMovePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } @@ -874,7 +834,6 @@ export function HomeScreen(props: HomeScreenProps) { ); }, [ - handleChangeRequestState, handleDeleteThread, arrangedPinnedKeys, handleMovePinnedThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 5c66944042add..dae6c46a89dd1 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -118,16 +118,6 @@ function useThreadActionExecutor( ); return false; } - // Settle may only target what effectiveSettled could classify as - // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would hide live work. - if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { - Alert.alert( - actionFailureTitle(action), - "This thread still needs attention. Resolve or interrupt it first, then try again.", - ); - return false; - } // Archive keeps its original, narrower guard: never interrupt a // thread mid-turn. if ( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index e781b97de99c3..81f5d4e986cf2 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -34,6 +34,9 @@ import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -527,26 +530,54 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; + const { savedConnectionsById } = useSavedRemoteConnections(); + const connections = Object.values(savedConnectionsById).sort((left, right) => + left.environmentLabel.localeCompare(right.environmentLabel), + ); return ( - savePreferences({ autoSettleOnMerge: value })} - /> + {connections.map((connection) => ( + + ))} ); } +function EnvironmentAutoSettleSwitch(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "auto-settle settings update", + reportFailure: true, + }); + if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) { + return null; + } + return ( + { + void updateSettings({ + environmentId: props.environmentId, + input: { patch: { sidebarAutoSettleOnMerge: value } }, + }); + }} + /> + ); +} + /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 512b9b78a4cab..4a4d36c7a211b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -9,7 +9,6 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -28,7 +27,6 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; @@ -82,7 +80,6 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "./threadListV2"; @@ -164,10 +161,6 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -365,33 +358,6 @@ function ThreadNavigationSidebarPane( // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule. - const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && - (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); // The settled tail renders in pages; expansion resets when the filter // context changes so environment/search flips never inherit a deep page. const [settledVisibleCount, setSettledVisibleCount] = useState( @@ -414,9 +380,7 @@ function ThreadNavigationSidebarPane( toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // now ticks per minute so the inactivity auto-settle boundary is actually - // crossed while the pane stays open; without a clock dependency the - // partition memoizes a frozen "now". + // The queued-start and snooze helpers need a clock while the pane stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -424,9 +388,7 @@ function ThreadNavigationSidebarPane( const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable: the mount-time value can be hours old - // by the time the beta is switched on, which would misclassify the - // inactivity auto-settle boundary until the first tick. + // Refresh immediately because the mount-time value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -509,20 +471,15 @@ function ThreadNavigationSidebarPane( projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, - now: `${nowMinute}:00.000Z`, - snoozeNow: new Date().toISOString(), + now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -931,7 +888,6 @@ function ThreadNavigationSidebarPane( onPinThread={pinThread} onUnpinThread={unpinThread} onMovePinnedThread={movePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1058,7 +1014,6 @@ function ThreadNavigationSidebarPane( arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, - handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 51b1ed7afcdb1..5ea43000bf1b3 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -23,12 +23,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { - resolveThreadListV2ChangeRequestState, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, - type ThreadListV2ChangeRequestState, type ThreadListV2Status, } from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -370,12 +368,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR (state + last activity) for the partition's - merge and close rules. Mirrors web's onChangeRequestState. */ - readonly onChangeRequestState?: ( - threadKey: string, - changeRequest: ThreadListV2ChangeRequestState | null, - ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -398,24 +390,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPinThread, onUnpinThread, onMovePinnedThread, - onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); - const prState = pr?.state ?? null; - const prUpdatedAt = pr?.updatedAt ?? null; - const threadKey = `${thread.environmentId}:${thread.id}`; - useEffect(() => { - const changeRequest = resolveThreadListV2ChangeRequestState({ - linkedPullRequest: thread.linkedPullRequest, - state: prState, - updatedAt: prUpdatedAt, - }); - if (changeRequest === undefined) return; - onChangeRequestState?.(threadKey, changeRequest); - }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]); const theme = useUniwindTheme(); const screenColor = theme["--color-screen"]; @@ -453,9 +432,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - // Swipe: the v2 primary action is the lifecycle transition. Every settled - // row can un-settle — explicit settles clear the override, auto-settled - // rows get pinned active until real activity clears the pin. + // Swipe: the v2 primary action is the lifecycle transition. Un-settling a + // settled row keeps it active until new activity clears the user override. const canUnsettle = variant === "slim"; const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); const snoozeGateExpiryMs = props.snoozeSupported diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 24c07eae6da15..48edf39060026 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -16,7 +16,6 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, - resolveThreadListV2ChangeRequestState, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -61,42 +60,6 @@ const linkedPullRequest = { url: "https://github.com/pingdotgg/t3code/pull/42", }; -describe("resolveThreadListV2ChangeRequestState", () => { - it("preserves the previous state while a linked pull request reloads", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest, - state: null, - updatedAt: null, - }), - ).toBeUndefined(); - }); - - it("clears the previous state after a pull request is unlinked", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest: null, - state: null, - updatedAt: null, - }), - ).toBeNull(); - }); - - it("reports a loaded linked pull request", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest, - state: "merged", - updatedAt: "2026-06-02T00:00:00.000Z", - }), - ).toEqual({ - state: "merged", - updatedAt: "2026-06-02T00:00:00.000Z", - linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', - }); - }); -}); - describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); @@ -319,51 +282,18 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { - it("ignores the previous pull request state after a different pull request is linked", () => { - const thread = makeThread({ - id: ThreadId.make("linked"), - title: "Linked pull request", - linkedPullRequest, - }); - const layout = buildThreadListV2Items({ - threads: [thread], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([ - [ - `${environmentId}:${thread.id}`, - { - state: "merged" as const, - linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]', - }, - ], - ]), - now: NOW, - }); - - expect(layout.settledCount).toBe(0); - expect(layout.items[0]?.variant).toBe("card"); - }); - - it("settles a thread only when the cached pull request identity matches", () => { + it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ id: ThreadId.make("linked-merged"), title: "Linked merged pull request", linkedPullRequest, + settledOverride: "settled", + settledAt: NOW, }); const layout = buildThreadListV2Items({ threads: [thread], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([ - [ - `${environmentId}:${thread.id}`, - { - state: "merged" as const, - linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', - }, - ], - ]), now: NOW, }); @@ -371,23 +301,6 @@ describe("buildThreadListV2Items", () => { expect(layout.items[0]?.variant).toBe("slim"); }); - it("keeps a merged thread active when auto-settle on merge is off", () => { - const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); - const layout = buildThreadListV2Items({ - threads: [merged], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([ - [`${environmentId}:${merged.id}`, { state: "merged" as const }], - ]), - autoSettleOnMerge: false, - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]); - expect(layout.settledCount).toBe(0); - }); - it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ @@ -439,73 +352,21 @@ describe("buildThreadListV2Items", () => { expect(layout.settledCount).toBe(1); }); - it("moves pinned threads to the settled shelf when their pull request merges", () => { - const merged = makeThread({ - id: ThreadId.make("pinned-merged"), - title: "Pinned merged pull request", - pinnedAt: "2026-06-01T12:00:00.000Z", - }); - const layout = buildThreadListV2Items({ - threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); - expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); - expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); - expect(layout.settledCount).toBe(1); - }); - - it("moves inactive pinned threads to the settled shelf", () => { - const inactive = makeThread({ - id: ThreadId.make("pinned-inactive"), - title: "Pinned inactive thread", - createdAt: "2026-05-20T00:00:00.000Z", - pinnedAt: "2026-05-21T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-inactive"), - state: "completed", - requestedAt: "2026-05-21T00:00:00.000Z", - startedAt: "2026-05-21T00:00:01.000Z", - completedAt: "2026-05-21T00:00:02.000Z", - assistantMessageId: null, - }, - }); - const layout = buildThreadListV2Items({ - threads: [inactive], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned-inactive" }, - variant: "slim", - pinned: false, - }); - expect(layout.settledCount).toBe(1); - }); - - it("keeps pinned merged threads pinned when auto-settle on merge is off", () => { - const merged = makeThread({ - id: ThreadId.make("pinned-merged"), - title: "Pinned merged pull request", + it("keeps active pinned threads in the pinned block", () => { + const pinned = makeThread({ + id: ThreadId.make("pinned"), + title: "Pinned thread", pinnedAt: "2026-06-01T12:00:00.000Z", }); const layout = buildThreadListV2Items({ - threads: [merged], + threads: [pinned], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - autoSettleOnMerge: false, now: NOW, }); expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned-merged" }, + thread: { id: "pinned" }, variant: "card", pinned: true, }); @@ -560,9 +421,7 @@ describe("buildThreadListV2Items", () => { ], environmentId: null, searchQuery: "", - // Minute-floored partition clock vs precise snooze clock. - now: "2026-06-02T00:01:00.000Z", - snoozeNow: "2026-06-02T00:01:07.500Z", + now: "2026-06-02T00:01:07.500Z", }); expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index be3343a21bad7..cf284b41605a1 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,22 +1,18 @@ import { - effectiveSettled, effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, resolveSnoozePresets, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; -import type { - ChangeRequestSettleSource, - SnoozePreset, -} from "@t3tools/client-runtime/state/thread-settled"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { activeThreadAnchorTimestampMs, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -33,35 +29,6 @@ export { snoozeWakeLabel }; export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; -export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource { - readonly linkedPullRequestKey?: string | null; -} - -function linkedPullRequestKey( - linkedPullRequest: ThreadLinkedPullRequest | null | undefined, -): string | null { - if (linkedPullRequest == null) return null; - return JSON.stringify([ - linkedPullRequest.projectId, - linkedPullRequest.repository.toLowerCase(), - linkedPullRequest.number, - ]); -} - -/** Keep the previous linked PR state while its detail query reloads. */ -export function resolveThreadListV2ChangeRequestState(input: { - readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined; - readonly state: ChangeRequestSettleSource["state"] | null; - readonly updatedAt: string | null; -}): ThreadListV2ChangeRequestState | null | undefined { - if (input.state === null) return input.linkedPullRequest == null ? null : undefined; - return { - state: input.state, - updatedAt: input.updatedAt, - linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest), - }; -} - export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; @@ -347,8 +314,7 @@ export function buildThreadListV2ListItems(input: { /** * Partitions visible threads into the active card block (creation order) and - * the settled recency tail, matching the web v2 list. Mobile stores these - * auto-settle preferences per device. + * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -359,8 +325,6 @@ export function buildThreadListV2Items(input: { }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; - /** Per-row PR reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ @@ -368,17 +332,10 @@ export function buildThreadListV2Items(input: { /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; - readonly autoSettleAfterDays?: number; - readonly autoSettleOnMerge?: boolean; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; - /** Injectable for tests; defaults to now. */ - readonly now?: string; - /** Second-precise clock for snooze classification. Callers pass a - minute-quantized `now` for memoization; snooze wake times are - second-precise, so classifying with the floored minute would hold a - woken thread hidden for up to a minute. Defaults to `now`. */ - readonly snoozeNow?: string; + /** Second-precise clock used for time-based classification. */ + readonly now: string; /** Expands the snoozed shelf into rows. Collapsed is the default. */ readonly snoozedShelfExpanded?: boolean; /** Expands the settled shelf into rows. Expanded is the default. */ @@ -387,10 +344,7 @@ export function buildThreadListV2Items(input: { a split-view detail can never lose its navigation row. */ readonly selectedThreadKey?: string | null; }): ThreadListV2Layout { - const now = input.now ?? new Date().toISOString(); - const snoozeNow = input.snoozeNow ?? now; - const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; - const autoSettleOnMerge = input.autoSettleOnMerge ?? true; + const now = input.now; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -402,8 +356,7 @@ export function buildThreadListV2Items(input: { const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { - // Callers pass live (unarchived) shells; settled threads are among them - // and partition into the tail via effectiveSettled. + // Callers pass live shells. The server stamps settledOverride for the tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; @@ -422,16 +375,8 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const cachedChangeRequest = - input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - const changeRequest = - cachedChangeRequest !== null && - (cachedChangeRequest.linkedPullRequestKey ?? null) === - linkedPullRequestKey(thread.linkedPullRequest) - ? cachedChangeRequest - : null; // Snooze outranks settlement and pinning until the thread wakes. - if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { + if (supportsSnooze && effectiveSnoozed(thread, { now })) { snoozed.push(thread); if ( thread.snoozedUntil != null && @@ -442,15 +387,7 @@ export function buildThreadListV2Items(input: { } continue; } - if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 5d0bd8a3c9dc6..cf4c29c6041c2 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -31,7 +31,6 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; - readonly autoSettleOnMerge?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -101,7 +100,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; threadListV2SettledShelfExpanded?: boolean; @@ -167,9 +165,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.autoSettleOnMerge === "boolean") { - preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; - } if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 6ade6025bcbcb..c43486623c4be 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -64,6 +64,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -376,6 +377,12 @@ export const makeOrchestrationIntegrationHarness = ( drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 11d4078de60ea..0de59db781603 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -209,6 +209,7 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, threadSettlement: true, + threadAutoSettlement: true, threadSnooze: true, environmentThemes: true, threadPinning: true, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 64b5d428ab116..fc2a2c81279dd 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -620,6 +620,7 @@ function makeManager(input?: { textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + gitConfigReads?: string[]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -629,11 +630,30 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); - const vcsDriverLayer = GitVcsDriver.layer.pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(serverConfigLayer), - ); + const vcsDriverLayer = input?.gitConfigReads + ? Layer.effect( + GitVcsDriver.GitVcsDriver, + GitVcsDriver.make.pipe( + Effect.map((service) => + GitVcsDriver.GitVcsDriver.of({ + ...service, + readConfigValue: (cwd, key) => + Effect.sync(() => input.gitConfigReads?.push(key)).pipe( + Effect.andThen(service.readConfigValue(cwd, key)), + ), + }), + ), + ), + ).pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ) + : GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, GitHubSourceControlProvider.make.pipe( @@ -955,6 +975,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("a warm PR cache does not reread repository identity for status", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]); + + const gitConfigReads: string[] = []; + const { manager } = yield* makeManager({ gitConfigReads }); + + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + gitConfigReads.length = 0; + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + + const identityReads = gitConfigReads.filter( + (key) => + key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url", + ); + expect(identityReads).toHaveLength(0); + }), + ); + it.effect("status skips the provider lookup for a branch that was never pushed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -974,6 +1018,377 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("branch PR lookup returns null when the repository has no remotes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const { manager, ghCalls } = yield* makeManager(); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toBeNull(); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => + 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/saved-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRefName: "main", + headRefName: "feature/saved-branch", + state: "OPEN", + updatedAt: "2026-04-03T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/saved-branch", + }); + + expect(pullRequest).toEqual({ + state: "open", + updatedAt: "2026-04-03T15:00:00.000Z", + }); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + }), + ); + + it.effect("branch PR lookup uses the default branch from a non-origin remote", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "upstream", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "develop"]); + yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]); + yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]); + yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]); + + const { manager } = yield* makeManager({ + ghScenario: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 221, + title: "Merged main PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/221", + baseRefName: "develop", + headRefName: "main", + state: "MERGED", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-08T15:00:00.000Z", + }); + }), + ); + + it.effect("branch PR lookup uses the saved name after the local branch is deleted", () => + 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/deleted-local-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to", + "origin/main", + "feature/deleted-local-branch/child", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 217, + title: "Deleted local branch PR", + url: "https://github.com/pingdotgg/t3code/pull/217", + baseRefName: "main", + headRefName: "feature/deleted-local-branch", + state: "MERGED", + updatedAt: "2026-04-04T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-local-branch", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-04T15:00:00.000Z", + }); + expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( + true, + ); + }), + ); + + it.effect("branch PR lookup recovers a deleted fork branch from its remote-tracking ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["push", "-u", "team/fork", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-fork-branch"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "contributor:feature/deleted-fork-branch": JSON.stringify([ + { + number: 218, + title: "Deleted fork branch PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/218", + baseRefName: "main", + headRefName: "feature/deleted-fork-branch", + state: "MERGED", + updatedAt: "2026-04-05T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-fork-branch", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-05T15:00:00.000Z", + }); + expect( + ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), + ).toBe(true); + }), + ); + + it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]); + const { manager, ghCalls } = yield* makeManager(); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.", + }); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:old-owner/old-repository.git", + originalRemoteDir, + ); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 219, + title: "Old repository PR", + url: "https://github.com/old-owner/old-repository/pull/219", + baseRefName: "main", + headRefName: "feature/repointed-lookup", + state: "MERGED", + updatedAt: "2026-04-06T15:00:00Z", + }, + ]), + "[]", + ], + }, + }); + + const first = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + expect(first?.state).toBe("merged"); + + const replacementRemoteDir = yield* createBareRemote(); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:new-owner/new-repository.git", + replacementRemoteDir, + ); + + const second = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + + expect(second).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + }), + ); + + it.effect("branch PR lookup shares the status cache for the same repository identity", () => + 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, ["checkout", "-b", "feature/shared-pr-cache"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 220, + title: "Shared cache PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/220", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "MERGED", + updatedAt: "2026-04-07T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/shared-pr-cache", + }); + + expect(status.pr?.state).toBe("merged"); + expect(pullRequest?.state).toBe("merged"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + + it.effect("branch PR lookup propagates provider failures", () => + 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/lookup-failure"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not available on PATH"), + }), + }, + }); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SourceControlProviderError"); + }), + ); + it.effect("status finds a merged PR after its remote branch was deleted", () => 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 4fb9f321206ee..393a8fd055922 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -90,6 +90,14 @@ export class GitManager extends Context.Service< input: VcsStatusInput, options?: GitVcsDriver.GitRemoteStatusOptions, ) => Effect.Effect; + /** Resolve the PR for a saved branch without changing the current checkout. */ + readonly branchPullRequest: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect< + { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, + GitManagerServiceError + >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -181,6 +189,7 @@ interface BranchHeadContext { preferredHeadSelector: string; remoteName: string | null; headRemoteUrlKey: string | null; + targetRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; @@ -938,15 +947,16 @@ export const make = Effect.gen(function* () { prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); }), ); - // Cache keys are NUL-joined [cwd, branch, upstreamRef, defaultBranch, epoch] — none of the - // segments can contain a NUL byte, and refs are never empty, so "" decodes - // back to a null ref. + // Cache keys are NUL-joined. Automatic settlement validates repository URLs + // against the cached value before it uses a pull request decision. const prLookupCacheKey = ( cwd: string, details: { branch: string; upstreamRef: string | null; defaultBranch: string | null; + localBranchExists?: boolean; + remoteName?: string | null; }, ) => [ @@ -954,6 +964,8 @@ export const make = Effect.gen(function* () { details.branch, details.upstreamRef ?? "", details.defaultBranch ?? "", + details.localBranchExists === false ? "0" : "1", + details.remoteName ?? "", String(prLookupEpoch(cwd)), ].join("\u0000"); // Consecutive failures per cache key, so a branch that keeps failing waits @@ -975,11 +987,20 @@ export const make = Effect.gen(function* () { }; const prLookupCache = yield* Cache.makeWith( (key: string) => { - const [cwd = "", branch = "", upstreamRef = "", defaultBranch = ""] = key.split("\u0000"); + const [ + cwd = "", + branch = "", + upstreamRef = "", + defaultBranch = "", + branchExists = "1", + remoteName = "", + ] = key.split("\u0000"); const details = { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, defaultBranch: defaultBranch.length > 0 ? defaultBranch : null, + localBranchExists: branchExists !== "0", + ...(remoteName.length > 0 ? { remoteName } : {}), }; return Effect.gen(function* () { const headContext = yield* resolveBranchHeadContext(cwd, details); @@ -1000,7 +1021,11 @@ export const make = Effect.gen(function* () { } // Only skip when the branch is untracked as well: anything carrying an // upstream keeps the old behaviour. - if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { + if ( + details.localBranchExists && + details.upstreamRef === null && + (yield* isUnpublishedBranch(cwd, headContext)) + ) { return { latest: null, headContext }; } const latest = yield* findLatestPrForHeadContext(cwd, headContext); @@ -1218,11 +1243,33 @@ export const make = Effect.gen(function* () { }; }); + const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( + function* (cwd: string, branch: string, remoteNameOverride?: string) { + const remoteName = + remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); + const [headRemote, targetRemote] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + return { + remoteName, + headRemoteUrlKey: + headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null), + targetRemoteUrlKey: targetRemote.remoteUrlKey, + }; + }, + ); + const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( cwd: string, - details: { branch: string; upstreamRef: string | null }, + details: { branch: string; upstreamRef: string | null; remoteName?: string }, ) { - const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); + const remoteName = + details.remoteName ?? + (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`)); const headBranchFromUpstream = details.upstreamRef ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) : ""; @@ -1286,6 +1333,7 @@ export const make = Effect.gen(function* () { headRemoteUrlKey: remoteRepository.remoteUrlKey ?? (remoteName === null ? originRepository.remoteUrlKey : null), + targetRemoteUrlKey: originRepository.remoteUrlKey, headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, @@ -1869,6 +1917,140 @@ export const make = Effect.gen(function* () { }); return mergeGitStatusParts(local, remote); }); + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( + "branchPullRequest", + )(function* ({ cwd, branch }) { + const cacheCwd = yield* normalizeStatusCacheKey(cwd); + const remotes = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remotes", + cwd: cacheCwd, + args: ["remote"], + }); + const remoteNames = remotes.stdout + .split("\n") + .map((remoteName) => remoteName.trim()) + .filter((remoteName) => remoteName.length > 0); + const [firstRemoteName] = remoteNames; + if (firstRemoteName === undefined) return null; + const branchRef = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.branchRef", + cwd: cacheCwd, + args: [ + "for-each-ref", + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", + `refs/heads/${branch}`, + ], + }); + const expectedRefName = `refs/heads/${branch}`; + const exactBranch = branchRef.stdout + .split("\n") + .find((line) => line.split("\u0000", 1)[0] === expectedRefName); + const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = + exactBranch?.split("\u0000") ?? []; + const localBranchExists = refName.length > 0; + let upstreamRef: string | null = null; + let remoteName: string | null = null; + if (savedUpstream.length > 0) { + if (savedRemoteName.length === 0 || savedRemoteRef.length === 0) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Saved upstream for ${branch} is incomplete.`, + }); + } + remoteName = savedRemoteName; + const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); + upstreamRef = `${remoteName}/${upstreamBranch}`; + } else if (!localBranchExists) { + const trackingRefs = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remoteTrackingRefs", + cwd: cacheCwd, + args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], + }); + const refNames = new Set( + trackingRefs.stdout + .split("\n") + .map((remoteRef) => remoteRef.trim()) + .filter((remoteRef) => remoteRef.length > 0), + ); + const matchingRemoteNames = remoteNames.filter((candidate) => + refNames.has(`refs/remotes/${candidate}/${branch}`), + ); + if (matchingRemoteNames.length > 1) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Multiple remotes track ${branch}. Its pull request is ambiguous.`, + }); + } + remoteName = matchingRemoteNames[0] ?? null; + if (remoteName !== null) { + upstreamRef = `${remoteName}/${branch}`; + } + } + const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; + const defaultBranch = yield* gitCore + .resolveDefaultBranchName(cacheCwd, defaultRemoteName) + .pipe(Effect.orElseSucceed(() => null)); + const cacheKey = prLookupCacheKey(cacheCwd, { + branch, + upstreamRef, + defaultBranch, + localBranchExists, + ...(localBranchExists ? {} : { remoteName }), + }); + let cached = yield* Cache.get(prLookupCache, cacheKey); + const currentIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + remoteName ?? undefined, + ); + const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + !( + (headContext.headRemoteUrlKey !== null && identity.headRemoteUrlKey === null) || + (headContext.targetRemoteUrlKey !== null && identity.targetRemoteUrlKey === null) + ); + const hasSameIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + headContext.headRemoteUrlKey === identity.headRemoteUrlKey && + headContext.targetRemoteUrlKey === identity.targetRemoteUrlKey; + if (!canVerifyIdentity(cached.headContext, currentIdentity)) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} could not be verified.`, + }); + } + if (!hasSameIdentity(cached.headContext, currentIdentity)) { + yield* Cache.invalidate(prLookupCache, cacheKey); + cached = yield* Cache.get(prLookupCache, cacheKey); + const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + remoteName ?? undefined, + ); + if ( + !canVerifyIdentity(cached.headContext, refreshedIdentity) || + !hasSameIdentity(cached.headContext, refreshedIdentity) + ) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} changed during pull request lookup.`, + }); + } + } + const { latest } = cached; + if (latest === null) return null; + if ( + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))) && + latest.state !== "open" + ) { + return null; + } + const statusPr = toStatusPr(latest); + return { state: statusPr.state, updatedAt: statusPr.updatedAt }; + }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { @@ -2416,6 +2598,7 @@ export const make = Effect.gen(function* () { localStatus, remoteStatus, status, + branchPullRequest, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts index 7abd567704f1e..dc29dcbfa6f8f 100644 --- a/apps/server/src/orchestration/Errors.ts +++ b/apps/server/src/orchestration/Errors.ts @@ -1,3 +1,4 @@ +import { ThreadId } from "@t3tools/contracts"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Schema from "effect/Schema"; @@ -40,6 +41,24 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< } } +export class OrchestrationThreadSettleBlockedError extends Schema.TaggedErrorClass()( + "OrchestrationThreadSettleBlockedError", + { + threadId: ThreadId, + }, +) { + override get message(): string { + return "This thread still needs attention. Resolve or interrupt it first, then try again."; + } +} + +export const OrchestrationCommandRejection = Schema.Union([ + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, +]); +export type OrchestrationCommandRejection = typeof OrchestrationCommandRejection.Type; +export const isOrchestrationCommandRejection = Schema.is(OrchestrationCommandRejection); + export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass()( "OrchestrationCommandPreviouslyRejectedError", { @@ -96,7 +115,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< export type OrchestrationDispatchError = | ProjectionRepositoryError - | OrchestrationCommandInvariantError + | OrchestrationCommandRejection | OrchestrationCommandIdConflictError | OrchestrationCommandPreviouslyRejectedError | OrchestrationProjectorDecodeError diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index ba23d56b5e07b..3952cf34bddb8 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -10,6 +10,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -17,10 +18,12 @@ import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; import { describe, expect, it } from "vite-plus/test"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { @@ -46,27 +49,30 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); -async function createOrchestrationSystem() { +function makeOrchestrationLayer() { const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-orchestration-engine-test-", }); - const orchestrationLayer = Layer.mergeAll( + return Layer.mergeAll( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), ), OrchestrationProjectionSnapshotQueryLive, ).pipe( - Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); - const runtime = ManagedRuntime.make(orchestrationLayer); +} + +async function createOrchestrationSystem() { + const runtime = ManagedRuntime.make(makeOrchestrationLayer()); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); return { @@ -218,6 +224,7 @@ describe("OrchestrationEngine", () => { } satisfies OrchestrationProjectionPipelineShape), ), Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -243,6 +250,205 @@ describe("OrchestrationEngine", () => { await runtime.dispose(); }); + effectIt.effect("preserves the blocked-settle error and persists its rejected receipt", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const receipts = yield* OrchestrationCommandReceipts.OrchestrationCommandReceiptRepository; + const projectId = ProjectId.make("project-blocked-settle"); + const threadId = ThreadId.make("thread-blocked-settle"); + const commandId = CommandId.make("cmd-blocked-settle"); + const createdAt = now(); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-blocked-settle-project-create"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project-blocked-settle", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-blocked-settle-thread-create"), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-blocked-settle-session-set"), + threadId, + createdAt, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }); + + const sequence = yield* engine.latestSequence; + const error = yield* engine + .dispatch({ type: "thread.settle", commandId, threadId }) + .pipe(Effect.flip); + const message = + "This thread still needs attention. Resolve or interrupt it first, then try again."; + expect(error).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId, + message, + }); + expect(Option.getOrNull(yield* receipts.getByCommandId({ commandId }))).toMatchObject({ + commandId, + aggregateKind: "thread", + aggregateId: threadId, + status: "rejected", + error: message, + resultSequence: sequence, + }); + expect(yield* engine.latestSequence).toBe(sequence); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); + + effectIt.effect( + "rejects persisted changes and live background work without blocking unrelated threads", + () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(now())); + const engine = yield* OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery; + const backgroundLiveness = yield* ThreadBackgroundLiveness.ThreadBackgroundLivenessService; + const projectId = ProjectId.make("project-auto-settle-guard"); + const guardedThreadId = ThreadId.make("thread-auto-settle-guarded"); + const unrelatedThreadId = ThreadId.make("thread-auto-settle-unrelated"); + const liveThreadId = ThreadId.make("thread-auto-settle-live"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-auto-settle-guard-project"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project-auto-settle-guard", + createdAt: now(), + }); + for (const threadId of [guardedThreadId, unrelatedThreadId, liveThreadId]) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-create-${threadId}`), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now(), + }); + } + + const beforeUpdate = yield* snapshots.getSnapshot(); + const snapshotSequence = beforeUpdate.snapshotSequence; + const originalUpdatedAt = beforeUpdate.threads.find( + (thread) => thread.id === guardedThreadId, + )?.updatedAt; + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-auto-settle-guard-meta"), + threadId: guardedThreadId, + branch: "new-branch", + }); + const afterUpdate = yield* snapshots.getSnapshot(); + expect(afterUpdate.threads.find((thread) => thread.id === guardedThreadId)?.updatedAt).toBe( + originalUpdatedAt, + ); + + const staleError = yield* engine + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-stale-snapshot"), + threadId: guardedThreadId, + snapshotSequence, + }) + .pipe(Effect.flip); + expect(staleError._tag).toBe("OrchestrationCommandInvariantError"); + + const livenessSnapshotSequence = yield* engine.latestSequence; + for (const [taskType, expectedLiveness] of [ + ["subagent", "working"], + ["local_bash", "monitoring"], + ] as const) { + backgroundLiveness.recordTaskLiveness({ + threadId: liveThreadId, + taskId: `task-${expectedLiveness}`, + taskType, + status: undefined, + kind: "started", + }); + expect(backgroundLiveness.getThreadBackgroundLiveness(liveThreadId)).toBe( + expectedLiveness, + ); + expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence); + + const livenessError = yield* engine + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`), + threadId: liveThreadId, + snapshotSequence: livenessSnapshotSequence, + }) + .pipe(Effect.flip); + expect(livenessError._tag).toBe("OrchestrationCommandInvariantError"); + expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence); + backgroundLiveness.clearThreadLiveness(liveThreadId); + } + + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"), + threadId: liveThreadId, + snapshotSequence: livenessSnapshotSequence, + }); + + const freshSnapshotSequence = yield* engine.latestSequence; + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-auto-settle-unrelated-meta"), + threadId: unrelatedThreadId, + title: "Unrelated update", + }); + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"), + threadId: guardedThreadId, + snapshotSequence: freshSnapshotSequence, + }); + + const settled = yield* snapshots.getSnapshot(); + expect( + settled.threads.find((thread) => thread.id === guardedThreadId)?.settledOverride, + ).toBe("settled"); + expect(settled.threads.find((thread) => thread.id === liveThreadId)?.settledOverride).toBe( + "settled", + ); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); + it("persists deterministic read models for repeated snapshot reads", async () => { const createdAt = now(); const system = await createOrchestrationSystem(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 423a44a6ff151..f6a928fdc704e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -33,6 +33,7 @@ import { toPersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { + isOrchestrationCommandRejection, OrchestrationCommandIdConflictError, OrchestrationCommandInvariantError, OrchestrationCommandPreviouslyRejectedError, @@ -43,6 +44,7 @@ import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -51,7 +53,6 @@ const isOrchestrationCommandPreviouslyRejectedError = Schema.is( OrchestrationCommandPreviouslyRejectedError, ); const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError); -const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError); interface CommandEnvelope { command: OrchestrationCommand; @@ -86,6 +87,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const crypto = yield* Crypto.Crypto; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -169,13 +171,37 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + if ( + envelope.command.type === "thread.auto-settle" && + (yield* eventStore.hasEventAfter({ + aggregateKind: "thread", + aggregateId: envelope.command.threadId, + sequenceExclusive: envelope.command.snapshotSequence, + })) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} changed before automatic settlement`, + }); + } + + if ( + envelope.command.type === "thread.auto-settle" && + threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} has live background work`, + }); + } + const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.mapError((cause) => - isOrchestrationCommandInvariantError(cause) + isOrchestrationCommandRejection(cause) ? cause : new OrchestrationCommandInvariantError({ commandType: envelope.command.type, @@ -307,7 +333,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { ), ); - if (isOrchestrationCommandInvariantError(error)) { + if (isOrchestrationCommandRejection(error)) { yield* commandReceiptRepository .upsert({ commandId: envelope.command.commandId, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index b05ce3b1e2357..1340480bce552 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -23,7 +24,7 @@ describe("OrchestrationReactor", () => { runtime = null; }); - it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => { + it("starts every orchestration reactor", async () => { const started: string[] = []; runtime = ManagedRuntime.make( @@ -64,6 +65,15 @@ describe("OrchestrationReactor", () => { drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { + start: () => { + started.push("thread-settlement-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, @@ -85,6 +95,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "thread-settlement-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fb7543e31af0e..649e803809db5 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* threadSettlementReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4ad35e54cca81..ff246128c44ce 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -3229,4 +3229,49 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); + + effectIt.effect("stops a ready provider session after automatic settlement", () => + Effect.gen(function* () { + const sessionStopped = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + stopSessionEffect: () => Deferred.succeed(sessionStopped, undefined).pipe(Effect.asVoid), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-auto-settle"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex_work"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + const beforeSettlement = yield* Effect.promise(() => harness.readModel()); + + yield* harness.engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-with-session"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: beforeSettlement.snapshotSequence, + }); + + yield* Deferred.await(sessionStopped); + yield* Effect.promise(() => harness.drain()); + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.settledOverride).toBe("settled"); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index a37f0958dd55c..84d472089d8d8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -61,7 +61,8 @@ type ProviderIntentEvent = Extract< | "thread.turn-interrupt-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" - | "thread.session-stop-requested"; + | "thread.session-stop-requested" + | "thread.settled"; } >; @@ -1490,6 +1491,24 @@ const make = Effect.gen(function* () { case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; + case "thread.settled": { + const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId); + if ( + Option.isNone(thread) || + thread.value.session == null || + thread.value.session.status === "stopped" + ) { + return; + } + yield* orchestrationEngine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make(`session-stop-for-settle:${event.commandId ?? event.eventId}`), + threadId: event.payload.threadId, + createdAt: event.occurredAt, + onlyIfSettled: true, + }); + return; + } } }); @@ -1528,7 +1547,8 @@ const make = Effect.gen(function* () { event.type === "thread.turn-interrupt-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || - event.type === "thread.session-stop-requested" + event.type === "thread.session-stop-requested" || + event.type === "thread.settled" ) { return yield* worker.enqueue(event); } diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts new file mode 100644 index 0000000000000..08d2d2af24af7 --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ProviderInstanceId, + ThreadId, + ProjectId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const makeThread = ( + overrides: Partial = {}, +): OrchestrationThreadShell => ({ + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: "/repo", + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, +}); + +const decide = ( + thread: OrchestrationThreadShell, + pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + settings: { days?: number | null; merge?: boolean } = {}, +) => + shouldAutoSettleThread({ + thread, + pullRequest, + now: NOW, + autoSettleAfterDays: settings.days === undefined ? 3 : settings.days, + autoSettleOnMerge: settings.merge ?? true, + }); + +describe("shouldAutoSettleThread", () => { + it("settles inactive threads and leaves never-used threads active", () => { + expect(decide(makeThread())).toBe(true); + expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false); + expect(decide(makeThread(), null, { days: null })).toBe(false); + }); + + it("keeps a thread active at the exact inactivity boundary", () => { + expect(decide(makeThread({ latestUserMessageAt: "2026-08-25T12:00:00.000Z" }))).toBe(false); + }); + + it("keeps open pull requests active", () => { + expect(decide(makeThread(), { state: "open", updatedAt: NOW })).toBe(false); + }); + + it("settles closed requests and honors the merge setting", () => { + expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true); + expect( + decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), + ).toBe(false); + }); + + it("does not settle again after user activity newer than the PR", () => { + expect( + decide( + makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), + { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, + { days: null }, + ), + ).toBe(false); + }); + + it("does not inherit a terminal pull request older than the thread", () => { + expect( + decide( + makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }), + { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" }, + { days: null }, + ), + ).toBe(false); + }); + + it("requires a comparable PR timestamp for immediate settlement", () => { + const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); + expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false); + expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false); + expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true); + }); + + it("uses user request time instead of completion time as the PR anchor", () => { + const thread = makeThread({ + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-25T00:00:00.000Z", + startedAt: "2026-08-25T00:01:00.000Z", + completedAt: "2026-08-27T00:00:00.000Z", + assistantMessageId: null, + }, + }); + expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); + }); + + it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { + expect(decide(makeThread({ settledOverride: "active" }))).toBe(false); + expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false); + expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false); + expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false); + expect(decide(makeThread({ backgroundLiveness: "working" }))).toBe(false); + expect(decide(makeThread({ backgroundLiveness: "monitoring" }))).toBe(false); + expect( + decide( + makeThread({ + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-1"), + lastError: null, + updatedAt: NOW, + }, + }), + ), + ).toBe(false); + expect( + decide(makeThread({ latestUserMessageAt: "2026-08-28T11:59:00.000Z", latestTurn: null })), + ).toBe(false); + }); + + it("allows a fresh completion to wake snooze before settlement", () => { + expect( + decide( + makeThread({ + snoozedAt: "2026-08-19T00:00:00.000Z", + snoozedUntil: "2026-08-29T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-woke"), + state: "completed", + requestedAt: "2026-08-18T00:00:00.000Z", + startedAt: "2026-08-18T00:01:00.000Z", + completedAt: "2026-08-20T00:00:00.000Z", + assistantMessageId: null, + }, + }), + ), + ).toBe(true); + }); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts new file mode 100644 index 0000000000000..5a10307956aa0 --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -0,0 +1,108 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export interface SettlementPullRequest { + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; +} + +const DAY_MS = 24 * 60 * 60 * 1_000; +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +function latestTimestamp(values: ReadonlyArray): string | null { + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const value of values) { + if (value == null) continue; + const valueMs = Date.parse(value); + if (valueMs > latestMs) { + latest = value; + latestMs = valueMs; + } + } + return latest; +} + +/** A recent user message stays queued until a turn adopts its timestamp. + * Absolute age bounds client clock skew in both directions and stops stale + * pre-adoption data from blocking the thread forever. */ +export function threadHasQueuedTurnStart( + thread: Pick, + now: string, +): boolean { + if (thread.latestUserMessageAt === null || thread.session?.status === "error") return false; + const messageAt = Date.parse(thread.latestUserMessageAt); + const age = Date.parse(now) - messageAt; + if (Number.isNaN(age) || Math.abs(age) > QUEUED_TURN_START_GRACE_MS) return false; + if (thread.latestTurn === null) return true; + return [ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].every((value) => value == null || Date.parse(value) < messageAt); +} + +function pullRequestSettles( + thread: Pick, + pullRequest: SettlementPullRequest, + autoSettleOnMerge: boolean, +): boolean { + if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { + return false; + } + if (pullRequest.updatedAt === null) return false; + const userAnchor = latestTimestamp([ + thread.createdAt, + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + ]); + if (userAnchor === null) return false; + const pullRequestAt = Date.parse(pullRequest.updatedAt); + const userAnchorAt = Date.parse(userAnchor); + if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; + return pullRequestAt >= userAnchorAt; +} + +export function shouldAutoSettleThread(input: { + readonly thread: OrchestrationThreadShell; + readonly pullRequest: SettlementPullRequest | null; + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly autoSettleOnMerge: boolean; +}): boolean { + const { thread, pullRequest } = input; + if (!isAutoSettlementCandidate(thread, input.now)) return false; + if (pullRequest !== null) { + if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) return true; + if (pullRequest.state === "open") return false; + } + if (input.autoSettleAfterDays === null) return false; + const activityAt = latestTimestamp([ + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + ]); + if (activityAt === null) return false; + return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS; +} + +/** Cheap checks that run before any source control lookup. */ +export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean { + if (thread.archivedAt !== null || thread.settledOverride !== null) return false; + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + if (thread.session?.status === "starting" || thread.session?.status === "running") return false; + if (thread.backgroundLiveness != null) return false; + if (threadHasQueuedTurnStart(thread, now)) return false; + if (thread.snoozedUntil == null || Date.parse(thread.snoozedUntil) <= Date.parse(now)) + return true; + const wokeOnError = + thread.session?.status === "error" && + (thread.snoozedAt == null || + Date.parse(thread.session.updatedAt) > Date.parse(thread.snoozedAt)); + const wokeOnCompletion = + thread.snoozedAt != null && + thread.latestTurn?.state === "completed" && + thread.latestTurn.completedAt != null && + Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt); + return wokeOnError || wokeOnCompletion; +} diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts new file mode 100644 index 0000000000000..5d8d47109faa3 --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -0,0 +1,640 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderInstanceId, + PullRequestOperationError, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type PullRequestDetail, + type ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { assert, describe, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import { GitManager } from "../git/GitManager.ts"; +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const PROJECT_ID = ProjectId.make("settlement-project"); +const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); + +type AutoSettleCommand = Extract; + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +function makeProject( + id: ProjectId = PROJECT_ID, + workspaceRoot = "/workspace/project", +): OrchestrationProjectShell { + return { + id, + title: `Project ${id}`, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: NOW, + }; +} + +function makeThread( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: PROJECT_ID, + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function makeSnapshot( + threads: ReadonlyArray, + projects: ReadonlyArray = [makeProject()], +): OrchestrationShellSnapshot { + return { + snapshotSequence: 1, + projects, + threads, + updatedAt: NOW, + }; +} + +function makePullRequestDetail(input: { + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt?: string; +}): PullRequestDetail { + return { + provider: "github", + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: true, + review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, + reviewers: { request: true, listCandidates: true }, + }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + }, + projectId: input.projectId, + projectTitle: "Linked project", + workspaceRoot: "/workspace/linked", + repository: input.repository, + number: input.number, + title: "Pull request", + body: "", + url: `https://example.test/${input.repository}/pull/${input.number}`, + author: null, + state: input.state, + isDraft: false, + mergeability: "mergeable", + additions: 0, + deletions: 0, + changedFiles: 0, + headBranch: "feature", + baseBranch: "main", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: input.updatedAt ?? NOW, + mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, + closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, + reviewers: [], + labels: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }; +} + +interface HarnessOptions { + readonly snapshot: OrchestrationShellSnapshot; + readonly settings?: ServerSettings; + readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; + readonly pullRequestDetail?: PullRequestService["Service"]["detail"]; + readonly onDispatch?: ( + command: AutoSettleCommand, + ) => Effect.Effect; +} + +const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) { + const activation = yield* Deferred.make(); + const snapshots = yield* Ref.make(options.snapshot); + const snapshotReadCount = yield* Ref.make(0); + const snapshotReads = yield* Queue.unbounded(); + const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); + const settingsChanges = yield* PubSub.unbounded(); + const commands = yield* Ref.make>([]); + const branchCalls = yield* Ref.make< + ReadonlyArray<{ readonly cwd: string; readonly branch: string }> + >([]); + const detailCalls = yield* Ref.make< + ReadonlyArray<{ + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + }> + >([]); + + const updateSettings = (patch: ServerSettingsPatch) => + Effect.gen(function* () { + const next = applyServerSettingsPatch(yield* Ref.get(settings), patch); + yield* Ref.set(settings, next); + yield* PubSub.publish(settingsChanges, next); + return next; + }); + + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => + Ref.update(branchCalls, (calls) => [...calls, input]).pipe( + Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), + ); + + const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) => + Ref.update(detailCalls, (calls) => [...calls, input]).pipe( + Effect.andThen( + options.pullRequestDetail?.(input) ?? + Effect.succeed( + makePullRequestDetail({ + ...input, + state: "open", + }), + ), + ), + ); + + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { + if (command.type !== "thread.auto-settle") { + return Effect.die(new Error(`Unexpected command: ${command.type}`)); + } + return Ref.update(commands, (recorded) => [...recorded, command]).pipe( + Effect.andThen(options.onDispatch?.(command) ?? Effect.void), + Effect.as({ sequence: 1 }), + ); + }; + + const serverSettings = ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(settings), + updateSettings, + streamChanges: Stream.fromPubSub(settingsChanges), + subscribeChanges: PubSub.subscribe(settingsChanges).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }); + + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( + Effect.tap((count) => Queue.offer(snapshotReads, count)), + Effect.andThen(Ref.get(snapshots)), + ), + }), + Layer.mock(GitManager)({ branchPullRequest }), + Layer.mock(PullRequestService)({ detail: pullRequestDetail }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(ServerSettingsService, serverSettings), + Layer.succeed(ServerActivation, Deferred.await(activation)), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + + return { + activation, + snapshots, + snapshotReadCount, + snapshotReads, + commands, + branchCalls, + detailCalls, + updateSettings, + layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), + }; +}); + +const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( + reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"], + activation: Deferred.Deferred, + snapshotReads: Queue.Queue, +) { + yield* reactor.start(); + yield* Deferred.succeed(activation, undefined); + yield* Queue.take(snapshotReads); + yield* reactor.drain; +}); + +describe("ThreadSettlementReactor", () => { + it.effect("starts without clients and skips protected threads before pull request lookup", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + } as const; + const skipped = [ + makeThread("pending-approval", { + branch: "skip-approval", + hasPendingApprovals: true, + }), + makeThread("snoozed", { + branch: "skip-snoozed", + snoozedUntil: "2026-08-29T00:00:00.000Z", + }), + ]; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("inactive", { branch: "inactive-feature" }), + makeThread("closed-pr", { linkedPullRequest }), + ...skipped, + ], + [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + branchPullRequest: () => Effect.succeed(null), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0); + + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + const commands = yield* Ref.get(fixture.commands); + assert.deepStrictEqual( + commands + .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence })) + .sort((left, right) => left.threadId.localeCompare(right.threadId)), + [ + { + threadId: ThreadId.make("closed-pr"), + snapshotSequence: 1, + }, + { + threadId: ThreadId.make("inactive"), + snapshotSequence: 1, + }, + ], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ + { cwd: "/workspace/project", branch: "inactive-feature" }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("reevaluates inactivity and pull request state once per minute", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const pullRequest = yield* Ref.make<"open" | "merged">("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("at-boundary", { + latestUserMessageAt: "2026-08-25T12:00:00.000Z", + }), + makeThread("open-pr", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + ]), + branchPullRequest: () => + Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + + yield* Ref.set(pullRequest, "merged"); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)) + .map((command) => command.threadId) + .sort((left, right) => left.localeCompare(right)), + [ThreadId.make("at-boundary"), ThreadId.make("open-pr")], + ); + assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make<"merged" | "closed">("merged"); + const firstLookupStarted = yield* Deferred.make(); + const releaseFirstLookup = yield* Deferred.make(); + const laterLookupStarted = yield* Deferred.make(); + const releaseLaterLookup = yield* Deferred.make(); + const lookupCount = yield* Ref.make(0); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + branchPullRequest: () => + Ref.updateAndGet(lookupCount, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(firstLookupStarted, undefined) + : count === 3 + ? Deferred.succeed(laterLookupStarted, undefined) + : Effect.void, + ), + Effect.tap((count) => + count === 1 + ? Deferred.await(releaseFirstLookup) + : count === 3 + ? Deferred.await(releaseLaterLookup) + : Effect.void, + ), + Effect.andThen(Ref.get(state)), + Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* Deferred.await(firstLookupStarted); + + yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false }); + yield* Deferred.succeed(releaseFirstLookup, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2); + + yield* Ref.set(state, "closed"); + yield* fixture.updateSettings({ enableAgentBrowserAccess: false }); + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 }); + yield* Deferred.await(laterLookupStarted); + yield* Deferred.succeed(releaseLaterLookup, undefined); + yield* reactor.drain; + + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3); + assert.strictEqual(yield* Ref.get(lookupCount), 3); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("settings-thread")], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps an unknown pull request active and continues with other candidates", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("lookup-failed", { + linkedPullRequest: { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 9, + url: "https://example.test/owner/repository/pull/9", + }, + }), + makeThread("inactive-without-pr"), + ], + [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + pullRequestDetail: () => + Effect.fail( + new PullRequestOperationError({ + operation: "detail", + detail: "host unavailable", + }), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("inactive-without-pr")], + ); + assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps threads active when their pull request project is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 10, + url: "https://example.test/owner/repository/pull/10", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("missing-own-project", { linkedPullRequest }), + makeThread("missing-branch-project", { branch: "saved-feature" }), + ], + [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "open" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 77, + url: "https://example.test/owner/repository/pull/77", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("branch-one", { + branch: "saved-feature", + worktreePath: "/deleted/worktree-one", + }), + makeThread("branch-two", { + branch: "saved-feature", + worktreePath: "/deleted/worktree-two", + }), + makeThread("linked-one", { linkedPullRequest }), + makeThread("linked-two", { linkedPullRequest }), + ], + [ + makeProject(PROJECT_ID, "/workspace/project-root"), + makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), + ], + ), + branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ + { cwd: "/workspace/project-root", branch: "saved-feature" }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 }, + ]); + assert.deepStrictEqual( + new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), + new Set([ + ThreadId.make("branch-one"), + ThreadId.make("branch-two"), + ThreadId.make("linked-one"), + ThreadId.make("linked-two"), + ]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("carries the snapshot guard and survives a stale dispatch rejection", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]), + onDispatch: (command) => + command.threadId === ThreadId.make("stale") + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "thread changed after settlement evaluation", + }), + ) + : Effect.void, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + const firstSweep = yield* Ref.get(fixture.commands); + assert.strictEqual( + firstSweep.find((command) => command.threadId === ThreadId.make("stale")) + ?.snapshotSequence, + 1, + ); + assert.strictEqual( + firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")), + true, + ); + + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts new file mode 100644 index 0000000000000..fd4486a9c406a --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -0,0 +1,185 @@ +import { CommandId } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as GitManager from "../git/GitManager.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; +import { + isAutoSettlementCandidate, + shouldAutoSettleThread, + type SettlementPullRequest, +} from "./ThreadSettlementPolicy.ts"; + +export class ThreadSettlementReactor extends Context.Service< + ThreadSettlementReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/orchestration/ThreadSettlementReactor") {} + +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const settingsService = yield* ServerSettings.ServerSettingsService; + const git = yield* GitManager.GitManager; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () { + const snapshot = yield* snapshots.getShellSnapshot(); + const now = DateTime.formatIso(yield* DateTime.now); + const projects = new Map(snapshot.projects.map((project) => [project.id, project])); + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + const lookupKey = (thread: (typeof candidates)[number]) => { + if (thread.linkedPullRequest != null) { + return JSON.stringify([ + "linked", + thread.linkedPullRequest.projectId, + thread.linkedPullRequest.repository, + thread.linkedPullRequest.number, + ]); + } + if (thread.branch === null) return JSON.stringify(["none", thread.id]); + const project = projects.get(thread.projectId); + return JSON.stringify( + project === undefined + ? ["missing-project", thread.id] + : ["branch", project.workspaceRoot, thread.branch], + ); + }; + const groups = Map.groupBy(candidates, lookupKey); + + const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( + thread: (typeof candidates)[number], + ) { + if (thread.linkedPullRequest != null) { + if (!projects.has(thread.linkedPullRequest.projectId)) { + return yield* Effect.die(new Error("linked pull request project not found")); + } + const detail = yield* pullRequests.detail({ + projectId: thread.linkedPullRequest.projectId, + repository: thread.linkedPullRequest.repository, + number: thread.linkedPullRequest.number, + }); + return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest; + } + if (thread.branch === null) return null; + const project = projects.get(thread.projectId); + if (project === undefined) { + return yield* Effect.die(new Error("thread project not found")); + } + return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch }); + }); + + yield* Effect.forEach( + groups.values(), + (group) => + Effect.gen(function* () { + const pullRequest = yield* pullRequestFor(group[0]!); + yield* Effect.forEach( + group, + (thread) => + Effect.gen(function* () { + const settings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + if ( + !shouldAutoSettleThread({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }) + ) { + return; + } + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), + threadId: thread.id, + snapshotSequence: snapshot.snapshotSequence, + }); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + { discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker(() => + sweep().pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement sweep failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( + "ThreadSettlementReactor.start", + )(function* () { + const settingsChanges = yield* settingsService.subscribeChanges; + const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); + let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; + let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue(undefined); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), + ); + yield* forkParked( + Stream.runForEach(settingsChanges, (settings) => { + if ( + settings.sidebarAutoSettleAfterDays === lastAfterDays && + settings.sidebarAutoSettleOnMerge === lastOnMerge + ) { + return Effect.void; + } + lastAfterDays = settings.sidebarAutoSettleAfterDays; + lastOnMerge = settings.sidebarAutoSettleOnMerge; + return worker.enqueue(undefined); + }), + ); + }); + + return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"]; +}); + +export const layer = Layer.effect(ThreadSettlementReactor, make); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 26927d4499d6e..e470ba33c790a 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -19,6 +19,8 @@ import { projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z"; const SETTLED_AT = "2025-12-30T00:00:00.000Z"; +const SETTLE_BLOCKED_MESSAGE = + "This thread still needs attention. Resolve or interrupt it first, then try again."; function makeReadModel( settledOverride: OrchestrationThread["settledOverride"], @@ -79,6 +81,22 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi } it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("rejects an automatic settle when the thread is pinned active", () => + Effect.gen(function* () { + const command = { + type: "thread.auto-settle" as const, + commandId: CommandId.make("cmd-auto-settle"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + }; + const pinnedActive = yield* decideOrchestrationCommand({ + command, + readModel: makeReadModel("active"), + }).pipe(Effect.flip); + expect(pinnedActive._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + it.effect("settles awake threads without a redundant wake and re-emits idempotently", () => Effect.gen(function* () { const event = yield* decideOrchestrationCommand({ @@ -198,7 +216,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }, readModel: makeReadModel(null, null, makeSession(status)), }).pipe(Effect.flip); - expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); } // Stopped/error sessions are settleable — only live work is protected. const settled = yield* decideOrchestrationCommand({ @@ -238,7 +260,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("approval.requested", "req-1", NOW), ]), }).pipe(Effect.flip); - expect(openError._tag).toBe("OrchestrationCommandInvariantError"); + expect(openError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); // Same request later resolved: settleable again. const settled = yield* decideOrchestrationCommand({ @@ -266,7 +292,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("user-input.requested", "req-2", NOW), ]), }).pipe(Effect.flip); - expect(inputError._tag).toBe("OrchestrationCommandInvariantError"); + expect(inputError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); }), ); @@ -287,8 +317,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { createdAt: NOW, }) as OrchestrationThread["activities"][number]; - // Stale-failure detail clears the request — mirrors the projection's - // pending accounting, which is what the client's canSettle sees. + // Stale-failure details clear the request, matching the projection flags. const settled = yield* decideOrchestrationCommand({ command: { type: "thread.settle", @@ -324,7 +353,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ]), }).pipe(Effect.flip); - expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError"); + expect(stillOpen).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); }), ); @@ -352,7 +385,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }, readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]), }).pipe(Effect.flip); - expect(queuedError._tag).toBe("OrchestrationCommandInvariantError"); + expect(queuedError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); // Message timestamp far in the FUTURE (client clock ahead of server): // a negative age must not read as queued forever — past the grace diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f3fdd462f437b..892475f624471 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,13 +3,18 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type OrchestrationThread, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import type * as PlatformError from "effect/PlatformError"; -import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, + type OrchestrationCommandRejection, +} from "./Errors.ts"; import { listThreadsByProjectId, requireActiveProjectWorkspaceRootAbsent, @@ -21,14 +26,10 @@ import { requireThreadNotArchived, } from "./commandInvariants.ts"; import { projectEvent } from "./projector.ts"; +import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -// Session adoption takes seconds; a user message still unadopted after this -// window is a failed/stale start, not pending work. Mirrors the client's -// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. -const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; - /** * Blocked-on-you work derived from the thread's retained activities: an * approval or user-input request with no later resolution for the same @@ -86,59 +87,28 @@ function hasOpenBlockingRequest(thread: { return openRequestIds.size > 0; } -/** - * A queued turn start — a user message no turn has picked up yet — is work - * in flight even though session is still null (turn.start emits - * message-sent + turn-start-requested; the session arrives later). Detection - * mirrors the client's hasQueuedTurnStart: the newest user message is - * strictly newer than every latestTurn timestamp (adoption stamps the new - * turn's requestedAt with the message time, clearing this), and only within - * the adoption grace window — historical threads whose last user message - * postdates their turn timestamps (older-server data, mid-turn messages) - * must not be blocked forever. A failed session start (status "error") - * clears the block immediately. - * - * The age check is bounded on BOTH sides: message timestamps are - * client-supplied, so a client clock ahead of the server yields a negative - * age. Without the lower bound that negative age satisfies `<= grace` for - * as long as the skew lasts, extending the block far past the intended two - * minutes. - */ -function threadHasQueuedTurnStart( - thread: { - readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; - readonly latestTurn: { - readonly requestedAt: string; - readonly startedAt: string | null; - readonly completedAt: string | null; - } | null; - readonly session: { readonly status: string } | null; - }, - occurredAt: string, +/** Apply the shared shell-level rule to the detailed command read model. */ +function hasQueuedTurnStartForThread( + thread: Pick, + now: string, ): boolean { - const latestUserMessageAtMs = thread.messages.reduce( - (latest, message) => - message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, - Number.NEGATIVE_INFINITY, - ); - const latestTurnAtMs = - thread.latestTurn === null - ? Number.NEGATIVE_INFINITY - : Math.max( - ...[ - thread.latestTurn.requestedAt, - thread.latestTurn.startedAt, - thread.latestTurn.completedAt, - ].map((candidate) => - candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), - ), - ); - const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; - return ( - thread.session?.status !== "error" && - Number.isFinite(latestUserMessageAtMs) && - latestUserMessageAtMs > latestTurnAtMs && - Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS + let latestUserMessageAt: string | null = null; + let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; + for (const message of thread.messages) { + if (message.role !== "user") continue; + const messageAtMs = Date.parse(message.createdAt); + latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); + if (messageAtMs === latestUserMessageAtMs) { + latestUserMessageAt = message.createdAt; + } + } + return threadHasQueuedTurnStart( + { + latestUserMessageAt: Number.isFinite(latestUserMessageAtMs) ? latestUserMessageAt : null, + latestTurn: thread.latestTurn, + session: thread.session, + }, + now, ); } @@ -186,7 +156,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< ReadonlyArray, - OrchestrationCommandInvariantError | PlatformError.PlatformError, + OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { let nextReadModel = readModel; @@ -220,7 +190,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< DecideOrchestrationCommandResult, - OrchestrationCommandInvariantError | PlatformError.PlatformError, + OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { switch (command.type) { @@ -450,43 +420,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } - case "thread.settle": { + case "thread.settle": + case "thread.auto-settle": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); - // Server-side twin of the client's canSettle session check: a stale - // or raced client must not settle a thread whose session is coming - // alive or working. - if (thread.session?.status === "starting" || thread.session?.status === "running") { + if (command.type === "thread.auto-settle" && thread.settledOverride !== null) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, - detail: `thread ${command.threadId} has an active session and cannot be settled`, + detail: `thread ${command.threadId} changed before automatic settlement`, }), ); } + // The server owns settle eligibility. A stale command must not settle + // a thread whose session is coming alive or working. + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); + } // Pending approval / user-input requests are blocked-on-you work: a // raced or stale client must not park them behind a settled override // that would surface only after the request resolves. if (hasOpenBlockingRequest(thread)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be settled`, - }), - ); + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } const occurredAt = yield* nowIso; // Settling inside the adoption window would hide just-requested work. - if (threadHasQueuedTurnStart(thread, occurredAt)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a queued turn start and cannot be settled`, - }), - ); + if (hasQueuedTurnStartForThread(thread, occurredAt)) { + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } // Settling an already-settled thread re-emits with the original // settledAt: the engine rejects zero-event commands, and bulk-settle / @@ -610,7 +573,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // invisible pending work: no session, no pending flags. Snoozing in // that window would hide a just-requested turn exactly the way settle // would. - if (threadHasQueuedTurnStart(thread, occurredAt)) { + if (hasQueuedTurnStartForThread(thread, occurredAt)) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -1152,7 +1115,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if ( thread.settledOverride !== "settled" || sessionComingAlive || - threadHasQueuedTurnStart(thread, command.createdAt) + hasQueuedTurnStartForThread(thread, command.createdAt) ) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index edd8620c3b3f6..e801c34af582a 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -64,7 +64,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ const HasEventAfterRequestSchema = Schema.Struct({ aggregateKind: Schema.String, aggregateId: Schema.String, - type: Schema.String, + type: Schema.optional(Schema.String), sequenceExclusive: NonNegativeInt, }); @@ -271,16 +271,17 @@ const makeEventStore = Effect.gen(function* () { const findEventAfter = SqlSchema.findOneOption({ Request: HasEventAfterRequestSchema, Result: Schema.Struct({ sequence: Schema.Number }), - execute: (request) => - sql` - SELECT sequence - FROM orchestration_events - WHERE aggregate_kind = ${request.aggregateKind} - AND stream_id = ${request.aggregateId} - AND event_type = ${request.type} - AND sequence > ${request.sequenceExclusive} - LIMIT 1 - `, + execute: (request) => sql` + SELECT sequence + FROM orchestration_events + WHERE aggregate_kind = ${request.aggregateKind} + AND stream_id = ${request.aggregateId} + AND ${sql.and([ + sql`sequence > ${request.sequenceExclusive}`, + ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]), + ])} + LIMIT 1 + `, }); const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index 488210ab74a5b..b865957c06b3e 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -54,7 +54,8 @@ export interface OrchestrationEventStoreShape { readonly readAll: () => Stream.Stream; /** - * Check whether an aggregate has an event of the given type after a sequence. + * Check whether an aggregate has an event after a sequence, optionally + * restricted to one event type. * * Used during replay to tell whether a later event supersedes the one being * applied, without streaming the rest of the log. @@ -62,7 +63,7 @@ export interface OrchestrationEventStoreShape { readonly hasEventAfter: (input: { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; readonly aggregateId: string; - readonly type: OrchestrationEvent["type"]; + readonly type?: OrchestrationEvent["type"]; readonly sequenceExclusive: number; }) => Effect.Effect; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 93a3977ddd0f8..8170901a21ffc 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -118,7 +118,10 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; -import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; +import { + OrchestrationListenerCallbackError, + OrchestrationThreadSettleBlockedError, +} from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; @@ -8036,7 +8039,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("stops the provider session after settle without closing terminals", () => + it.effect("leaves settle cleanup to the event reactor", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-settle"); const effects: string[] = []; @@ -8094,64 +8097,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.settle", "dispatch:thread.session.stop"]); - const sessionStopCommand = dispatchedCommands[1]; - assert.equal(sessionStopCommand?.type, "thread.session.stop"); - if (sessionStopCommand?.type === "thread.session.stop") { - assert.equal(sessionStopCommand.threadId, threadId); - assert.equal(sessionStopCommand.commandId, "session-stop-for-settle:cmd-thread-settle"); - assert.equal(sessionStopCommand.onlyIfSettled, true); - } + assert.deepEqual(effects, ["dispatch:thread.settle"]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.settle"], + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("settles without dispatching session stop when the thread has no session", () => + it.effect("forwards the friendly blocked-settlement message over websocket rpc", () => Effect.gen(function* () { - const threadId = ThreadId.make("thread-settle-no-session"); - const effects: string[] = []; - const dispatchedCommands: Array = []; - + const threadId = ThreadId.make("thread-settle-blocked"); yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { - dispatch: (command) => - Effect.sync(() => { - dispatchedCommands.push(command); - effects.push(`dispatch:${command.type}`); - return { sequence: dispatchedCommands.length }; - }), - }, - projectionSnapshotQuery: { - getThreadShellById: () => - Effect.succeed( - Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), - ), + dispatch: () => Effect.fail(new OrchestrationThreadSettleBlockedError({ threadId })), }, }, }); const wsUrl = yield* getWsServerUrl("/ws"); - const dispatchResult = yield* Effect.scoped( + const error = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.settle", - commandId: CommandId.make("cmd-thread-settle-no-session"), + commandId: CommandId.make("cmd-thread-settle-blocked"), threadId, }), - ), + ).pipe(Effect.flip), ); - assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.settle"]); - assert.deepEqual( - dispatchedCommands.map((command) => command.type), - ["thread.settle"], + assert.equal(error._tag, "OrchestrationDispatchCommandError"); + assert.equal( + error.message, + "This thread still needs attention. Resolve or interrupt it first, then try again.", ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9a05fd18517eb..631902ac087d5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -62,6 +62,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -257,6 +258,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(ThreadSettlementReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -290,6 +292,13 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay Layer.provideMerge(VcsDriverRegistryLayerLive), ); +const PullRequestServiceLive = PullRequestService.layer.pipe( + Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(SourceControlProviderRegistryLayerLive), + Layer.provide(SourceControlRateLimit.layer), + Layer.provide(VcsProcess.layer), +); + const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunner.layer), Layer.provideMerge(GitVcsDriver.layer), @@ -389,7 +398,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), - Layer.provideMerge(SourceControlProviderRegistryLayerLive), + Layer.provideMerge( + Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive), + ), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), @@ -458,14 +469,6 @@ const commandReadinessLayer = HttpRouter.middleware( { global: true }, ); -const PullRequestServiceLive = PullRequestService.layer.pipe( - // One registry entry per supported host; the service only knows the registry. - Layer.provide(PullRequestProviderRegistry.layer), - Layer.provide(SourceControlProviderRegistryLayerLive), - Layer.provide(SourceControlRateLimit.layer), - Layer.provide(VcsProcess.layer), -); - export const makeRoutesLayer = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 521865839bde4..82ed8525b9b5e 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -272,6 +272,34 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists and broadcasts thread settlement settings", () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* serverSettings.subscribeChanges; + + const next = yield* serverSettings.updateSettings({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // Inspect raw persisted JSON before schema decoding can apply defaults. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw) as Record; + + assert.strictEqual(next.sidebarAutoSettleAfterDays, null); + assert.isFalse(next.sidebarAutoSettleOnMerge); + assert.strictEqual(change?.sidebarAutoSettleAfterDays, null); + assert.isFalse(change?.sidebarAutoSettleOnMerge); + assert.strictEqual(persisted.sidebarAutoSettleAfterDays, null); + assert.isFalse(persisted.sidebarAutoSettleOnMerge); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves model when switching providers via textGenerationModelSelection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4da8420799e99..db3b74b7e4b6b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1223,23 +1223,17 @@ const makeWsRpcLayer = ( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); - // Archive and settle both mean "done with this thread", so a - // live provider session must not keep running background work - // (PR monitors, dev servers, subagent fleets) after either - // lands. The decider rejects settling a starting/running - // session, so for settle this only ever stops an idle one; a - // stopped session-set does not count as activity, so the stop - // cannot un-settle the thread it follows. - const parkingCommand = - normalizedCommand.type === "thread.archive" || - normalizedCommand.type === "thread.settle" - ? normalizedCommand - : undefined; - // Best-effort on purpose: the user's archive/settle must not + // Archive removes the thread from the client, so this transport + // closes its session and terminals after the command lands. + // Settlement cleanup is driven by thread.settled events in the + // provider reactor, including settlements that have no client. + const archiveCommand = + normalizedCommand.type === "thread.archive" ? normalizedCommand : undefined; + // Best-effort on purpose: the user's archive must not // fail because this cleanup read blipped, so a failed read // logs and skips the stop instead of propagating. - const shouldStopSessionAfterCommand = parkingCommand - ? yield* projectionSnapshotQuery.getThreadShellById(parkingCommand.threadId).pipe( + const shouldStopSessionAfterCommand = archiveCommand + ? yield* projectionSnapshotQuery.getThreadShellById(archiveCommand.threadId).pipe( Effect.map( Option.match({ onNone: () => false, @@ -1250,7 +1244,7 @@ const makeWsRpcLayer = ( Effect.catchCause((cause) => Effect.logWarning( "failed to read thread session state before session-stop check", - { threadId: parkingCommand.threadId, cause }, + { threadId: archiveCommand.threadId, cause }, ).pipe(Effect.as(false)), ), ) @@ -1259,50 +1253,39 @@ const makeWsRpcLayer = ( Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), ); yield* recordClientCommandAnalytics(normalizedCommand); - if (parkingCommand) { - const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; + if (archiveCommand) { if (shouldStopSessionAfterCommand) { yield* Effect.gen(function* () { const stopCommand = yield* normalizeDispatchCommand({ type: "thread.session.stop", commandId: CommandId.make( - `session-stop-for-${parkingKind}:${parkingCommand.commandId}`, + `session-stop-for-archive:${archiveCommand.commandId}`, ), - threadId: parkingCommand.threadId, + threadId: archiveCommand.threadId, createdAt: yield* nowIso, - // A settled thread can be re-engaged before this stop is - // decided; the decider then drops the stop instead of - // killing the new session. Archive stops stay - // unconditional: turn starts on archived threads are - // rejected, so there is no new session to protect. - ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}), }); yield* dispatchNormalizedCommand(stopCommand); }).pipe( Effect.catchCause((cause) => - Effect.logWarning(`failed to stop provider session during ${parkingKind}`, { - threadId: parkingCommand.threadId, + Effect.logWarning("failed to stop provider session during archive", { + threadId: archiveCommand.threadId, cause, }), ), ); } - // Terminals are user-opened panes, not thread background - // work: archive removes the thread from view so they close - // with it, but a settled thread stays reachable and may be - // un-settled, so its terminals stay up. - if (parkingCommand.type === "thread.archive") { - yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: parkingCommand.threadId, - error: error.message, - }), - ), - ); - } + // Archive removes the thread from view, so its user-opened + // terminal panes close with it. + yield* terminalManager.close({ threadId: archiveCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: archiveCommand.threadId, + error: error.message, + }), + ), + ); } return result; }).pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index be978a8cc0c70..c222bb3bf0a21 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -30,12 +30,7 @@ import { } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; -import { - changeRequestAutoSettles, - effectiveSettled, - effectiveSnoozed, - threadWokeAt, -} from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -4539,9 +4534,7 @@ function ChatViewContent(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); - // Settled state of the open thread, resolved exactly like the sidebar - // partition (same shell, same capability gate, same PR auto-settle input) - // so the banner and the sidebar row never disagree. + // The server-projected settled state keeps the banner and sidebar in sync. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const activeComposerTasksProgress = useMemo(() => { if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) { @@ -4561,7 +4554,6 @@ function ChatViewContent(props: ChatViewProps) { activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId ? activePlan.steps : null; - useLayoutEffect(() => { if (!composerOverlayElement) return; @@ -4585,9 +4577,6 @@ function ChatViewContent(props: ChatViewProps) { resizeObserver.disconnect(); }; }, [composerOverlayElement]); - - const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); const linkedPullRequestStatus = useLinkedThreadPullRequest( activeThreadRef?.environmentId ?? null, linkedThreadPullRequest, @@ -4608,18 +4597,6 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadPr, openThreadPullRequest]); const pullRequestSurfaceAvailable = supportsPullRequests && activeThreadPr !== null && threadRepository !== null; - // Primitive slice of the displayed PR for the settle-rule memos below: - // resolveDisplayedThreadPr returns a fresh object every render, so memoize - // on the fields the rules read instead of the object identity. - const activeThreadPrState = activeThreadPr?.state ?? null; - const activeThreadPrUpdatedAt = activeThreadPr?.updatedAt ?? null; - const activeThreadChangeRequest = useMemo( - () => - activeThreadPrState === null - ? null - : { state: activeThreadPrState, updatedAt: activeThreadPrUpdatedAt }, - [activeThreadPrState, activeThreadPrUpdatedAt], - ); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; @@ -4650,21 +4627,13 @@ function ChatViewContent(props: ChatViewProps) { if (activeThreadRef === null || activeThreadWokeAt === null) return; markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); - // Mirror of the sidebar's Woke pill for the open thread. It uses the same - // visit comparison and change request settle rule. + // Mirror of the sidebar's Woke pill for the open thread. const activeThreadLastVisitedAt = useUiStateStore((store) => activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], ); const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; - if ( - changeRequestAutoSettles(activeThreadChangeRequest, { - autoSettleOnMerge, - thread: activeThreadShell, - }) - ) { - return false; - } + if (activeThreadShell?.settledOverride === "settled") return false; const wokeAtMs = Date.parse(activeThreadWokeAt); if (Number.isNaN(wokeAtMs)) return false; // Having the thread open counts as a visit at completedAt (the effect @@ -4684,28 +4653,11 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, - activeThreadChangeRequest, activeThreadShell, activeThreadWokeAt, - autoSettleOnMerge, - ]); - const activeThreadSettled = useMemo(() => { - if (activeThreadShell === null || !supportsSettlement) return false; - return effectiveSettled(activeThreadShell, { - now: `${nowMinute}:00.000Z`, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest: activeThreadChangeRequest, - }); - }, [ - activeThreadChangeRequest, - activeThreadShell, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestSnapshotByKey, - nowMinute, - supportsSettlement, ]); + const activeThreadSettled = + supportsSettlement && activeThreadShell?.settledOverride === "settled"; const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); @@ -7158,7 +7110,6 @@ function ChatViewContent(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} isServerThread={isServerThread} - changeRequest={activeThreadChangeRequest} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} activeProjectFaviconPath={activeProject?.faviconPath ?? null} diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 6e359f85c588b..2bcdaf7616280 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -618,11 +618,8 @@ type SettledTimestampInput = Pick< "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" >; -/** The timestamp a settled row sorts and labels by: settledAt when stamped - (explicit settles), otherwise last activity — the same candidates - threadLastActivityAt feeds the auto-settle window (user message plus all - latestTurn stamps), so a thread whose last activity was a turn completion - doesn't sort by an older message time. updatedAt is the final net. */ +/** The timestamp a settled row sorts and labels by: settledAt when stamped, + otherwise the latest message or turn stamp. updatedAt is the final net. */ export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { const settledAt = firstValidTimestamp(thread.settledAt); if (settledAt !== null) return settledAt; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a5df7bc535389..0b85065e134c7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -19,8 +19,6 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { canSnooze, - changeRequestAutoSettles, - effectiveSettled, effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; @@ -715,7 +713,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; - autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; // Pinned threads show the same pin marker in active, settled, and snoozed @@ -840,10 +837,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - !changeRequestAutoSettles(pr, { - autoSettleOnMerge: props.autoSettleOnMerge, - thread, - }); + thread.settledOverride !== "settled"; // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -1740,8 +1734,6 @@ export default function Sidebar() { const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -1938,8 +1930,6 @@ export default function Sidebar() { [projectGroups], ); - // now is quantized to the minute so effectiveSettled memoization doesn't - // churn on every render; auto-settle thresholds are day-granular anyway. const nowMinute = useNowMinute(); // Snooze wake times are second-precise, so classifying with the quantized // minute would hold a woken thread on the shelf for up to a minute. The @@ -2081,7 +2071,6 @@ export default function Sidebar() { settledThreads, snoozeNow, } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; // Snooze classification uses a REAL clock, not the quantized minute: // wake times are second-precise and a woken thread must not linger on // the shelf for the rest of the minute. snoozeWakeTick re-runs this @@ -2107,29 +2096,10 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const snapshot = changeRequestSnapshotByKey.get(threadKey); - const changeRequest = - snapshot != null && - (thread.linkedPullRequest == null - ? thread.worktreePath === null || snapshot.branch === thread.branch - : snapshot.linkedPullRequest?.projectId === thread.linkedPullRequest.projectId && - snapshot.linkedPullRequest.repository === thread.linkedPullRequest.repository && - snapshot.linkedPullRequest.number === thread.linkedPullRequest.number) - ? snapshot.pr - : null; // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + } else if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); @@ -2163,16 +2133,7 @@ export default function Sidebar() { settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; - }, [ - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestSnapshotByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -3111,9 +3072,8 @@ export default function Sidebar() { thread.worktreePath ?? projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null; - // Un-settle works on every settled row: for explicit settles it - // clears the override, for auto-settled rows it pins the thread - // active until real activity clears the pin. Environments without + // Un-settle pins the thread active until real activity clears the pin. + // Environments without // the settlement capability get no lifecycle items at all. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === @@ -3777,9 +3737,7 @@ export default function Sidebar() { key={`${threadKey}:${rowVariant}`} thread={thread} variant={rowVariant} - // Snoozed rows wake; settled rows un-settle (explicit - // settles clear the override, auto-settled rows get - // pinned active); cards settle. + // Snoozed rows wake, settled rows un-settle, and cards settle. variantAction={ section === "snoozed" ? "unsnooze" @@ -3791,7 +3749,6 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } - autoSettleOnMerge={autoSettleOnMerge} snoozeSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3710bcea8b8ea..2663af161b552 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,6 +1,4 @@ -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; -import type { OrchestrationThreadShell } from "@t3tools/contracts"; -import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; +import { ProjectId, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -495,7 +493,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { ).toEqual(mergedPr); }); - it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { + it("retains a merged PR after a main checkout", () => { const matchingStatus = status({ refName: featureBranch, pr: mergedPr, @@ -518,36 +516,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { retainTerminalOnBranchMismatch: true, }); expect(displayed?.state).toBe("merged"); - - const shell = { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Feature thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - latestTurn: null, - session: null, - createdAt: "2026-04-09T00:00:00.000Z", - updatedAt: "2026-04-09T00:00:00.000Z", - archivedAt: null, - settledAt: null, - settledOverride: null, - latestUserMessageAt: "2026-04-09T00:00:00.000Z", - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - } as OrchestrationThreadShell; - - expect( - effectiveSettled(shell, { - now: "2026-04-10T00:00:00.000Z", - autoSettleAfterDays: null, - changeRequest: displayed, - }), - ).toBe(true); }); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index c9c733e10f19a..6964702726efe 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -10,7 +10,6 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; import { ChevronDownIcon } from "lucide-react"; import { memo, @@ -53,8 +52,6 @@ interface ChatHeaderProps { activeThreadTitle: string; /** Drafts have no server thread yet, so the title carries no action menu. */ isServerThread: boolean; - /** PR feeding the settled classification, resolved by ChatView. */ - changeRequest: ChangeRequestSettleSource | null; activeProjectName: string | undefined; activeProjectCwd: string | null; activeProjectFaviconPath: string | null; @@ -123,7 +120,6 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, isServerThread, - changeRequest, activeProjectName, activeProjectCwd, activeProjectFaviconPath, @@ -201,7 +197,6 @@ export const ChatHeader = memo(function ChatHeader({ const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, - changeRequest, onStartRename: startRename, }); const titleButtonRef = useRef(null); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b150533b00449..8516058169753 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -79,7 +79,11 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; +import { + primaryServerConfigAtom, + primaryServerObservabilityAtom, + primaryServerProvidersAtom, +} from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -1871,6 +1875,8 @@ export function GeneralSettingsPanel() { ); const observability = useAtomValue(primaryServerObservabilityAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); + const supportsAutoSettlement = + useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true; const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, @@ -1954,72 +1960,77 @@ export function GeneralSettingsPanel() { } /> - - updateSettings({ - sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, - }) - } - /> - ) : null - } - control={ - - updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) + {supportsAutoSettlement ? ( + <> + + updateSettings({ + sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) + } + aria-label="Auto-settle merged threads" + /> } - aria-label="Auto-settle merged threads" /> - } - /> - - updateSettings({ - sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, - }) + + updateSettings({ + sidebarAutoSettleAfterDays: + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> } - aria-label="Auto-settle inactive threads" /> - } - /> - {settings.sidebarAutoSettleAfterDays !== null ? ( - updateSettings({ sidebarAutoSettleAfterDays: days })} + {settings.sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } /> - } - /> + ) : null} + ) : null} { it("hides desktop-only settings from browser search", () => { expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); - expect(searchSettings("quit confirmation")).toEqual([]); + expect(searchSettings("hold to quit")).toEqual([]); expect(searchSettings("wsl")).toEqual([]); }); @@ -134,6 +134,7 @@ describe("searchSettings", () => { hasProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, }); const gatedIds = new Set([ @@ -147,10 +148,30 @@ describe("searchSettings", () => { "t3-connect", "tailscale-https", "wsl-backend", + "auto-settle-inactive-threads", + "auto-settle-merged-threads", + "days-before-auto-settle", ]); expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); + it("shows automatic settlement settings when the server supports them", () => { + const available = filterAvailableSettingsSearchItems({ + hasCloudPublicConfig: false, + hasPrimaryEnvironment: false, + hasProviderSettingsEnvironment: false, + canManageLocalBackend: false, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: true, + }); + + expect(searchSettings("auto-settle", available).map((item) => item.id)).toEqual([ + "auto-settle-inactive-threads", + "auto-settle-merged-threads", + "days-before-auto-settle", + ]); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 1a061a4632a69..0432994962a67 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -30,6 +30,7 @@ export interface SettingsSearchItem { readonly providerSettingsOnly?: boolean; readonly localBackendManagementOnly?: boolean; readonly wslAvailableOnly?: boolean; + readonly requiresThreadAutoSettlement?: boolean; } export interface SettingsSearchAvailability { @@ -38,6 +39,7 @@ export interface SettingsSearchAvailability { readonly hasProviderSettingsEnvironment: boolean; readonly canManageLocalBackend: boolean; readonly isWslSettingsRowVisible: boolean; + readonly hasThreadAutoSettlement: boolean; } /** @@ -148,12 +150,14 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Auto-settle inactive threads", to: "/settings/general", searchTerms: ["sidebar inactivity days no activity automatically"], + requiresThreadAutoSettlement: true, }, { id: "auto-settle-merged-threads", title: "Auto-settle merged threads", to: "/settings/general", searchTerms: ["pull request merge closed automatically sidebar"], + requiresThreadAutoSettlement: true, }, { id: "days-before-auto-settle", @@ -161,6 +165,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", targetId: "auto-settle-inactive-threads", searchTerms: ["thread timeout activity sidebar"], + requiresThreadAutoSettlement: true, }, { id: "time-format", @@ -461,7 +466,8 @@ export function filterAvailableSettingsSearchItems( (!item.primaryOnly || availability.hasPrimaryEnvironment) && (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && (!item.localBackendManagementOnly || availability.canManageLocalBackend) && - (!item.wslAvailableOnly || availability.isWslSettingsRowVisible), + (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && + (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement), ); } diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index 2d426d763ed27..a2f5ca62766f0 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useAtomValue } from "@effect/atom-react"; import { AuthAccessWriteScope } from "@t3tools/contracts"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; @@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { usePrimarySessionState } from "~/environments/primary"; +import { primaryServerConfigAtom } from "~/state/server"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; import { filterAvailableSettingsSearchItems } from "./settingsSearch"; @@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() { const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); const primarySessionState = usePrimarySessionState(); + const primaryServerConfig = useAtomValue(primaryServerConfigAtom); const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); const canManageLocalBackend = isElectron || @@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() { state: desktopWsl.data, error: desktopWsl.error, }), + hasThreadAutoSettlement: + primaryServerConfig?.environment.capabilities.threadAutoSettlement === true, }), - [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments, primaryEnvironmentId], + [ + canManageLocalBackend, + desktopWsl.data, + desktopWsl.error, + environments, + primaryEnvironmentId, + primaryServerConfig, + ], ); } diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts index 1b9f77b2189b9..81168e5459ccc 100644 --- a/apps/web/src/hooks/useNowMinute.ts +++ b/apps/web/src/hooks/useNowMinute.ts @@ -1,10 +1,7 @@ import { useSyncExternalStore } from "react"; -/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution. - One module-level timer feeds every consumer through useSyncExternalStore, - so all surfaces resolving effectiveSettled against it (sidebar partition, - composer banner) share a single value by construction and tick on UTC - minute boundaries together. */ +/** Minute-quantized UI clock ("YYYY-MM-DDTHH:MM"). One module-level timer + feeds every consumer through useSyncExternalStore. */ function currentMinute(): string { return new Date().toISOString().slice(0, 16); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index b332fe13c2f10..b3009b2605209 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -76,4 +76,22 @@ describe("mergeEnvironmentSettings", () => { expect(settings.providerInstances).toBe(serverSettings.providerInstances); expect(settings.favorites).toBe(clientSettings.favorites); }); + + it("keeps server settlement settings when legacy client data contains retired keys", () => { + const serverSettings = { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 14, + sidebarAutoSettleOnMerge: false, + }; + const legacyClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + sidebarAutoSettleAfterDays: 1, + sidebarAutoSettleOnMerge: true, + }; + + const settings = mergeEnvironmentSettings(serverSettings, legacyClientSettings); + + expect(settings.sidebarAutoSettleAfterDays).toBe(14); + expect(settings.sidebarAutoSettleOnMerge).toBe(false); + }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 5e633a5ded59b..39ec7a70ce421 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -216,7 +216,9 @@ export function mergeEnvironmentSettings( serverSettings: ServerSettings, clientSettings: ClientSettings, ): UnifiedSettings { - return { ...serverSettings, ...clientSettings }; + // Decode drops retired client keys, but older untyped persistence adapters + // can still return them. Server-owned values must always win. + return { ...clientSettings, ...serverSettings }; } function useMergedSettings( diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 1602e5cf816dd..a073d847051bb 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -5,12 +5,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { - canSnooze, - effectiveSettled, - effectiveSnoozed, - type ChangeRequestSettleSource, -} from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useCallback } from "react"; @@ -60,11 +55,9 @@ export function useThreadActionMenu(input: { readonly threadRef: ScopedThreadRef | null; /** Fallback for "Copy path" when the thread has no worktree. */ readonly projectCwd: string | null; - /** PR feeding auto-settle classification, as resolved by the caller. */ - readonly changeRequest: ChangeRequestSettleSource | null; readonly onStartRename: () => void; }) { - const { threadRef, projectCwd, changeRequest, onStartRename } = input; + const { threadRef, projectCwd, onStartRename } = input; const { settleThread, unsettleThread, @@ -80,8 +73,6 @@ export function useThreadActionMenu(input: { }); const handleNewThread = useNewThreadHandler(); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); @@ -127,17 +118,7 @@ export function useThreadActionMenu(input: { const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, isPinned: thread.pinnedAt != null, - isSettled: - supports.settlement && - effectiveSettled(thread, { - // Minute-quantized like useNowMinute, so this classification - // can never disagree with the sidebar partition or ChatView's - // parked-thread banner within the same minute. - now: `${now.toISOString().slice(0, 16)}:00.000Z`, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }), + isSettled: supports.settlement && thread.settledOverride === "settled", isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, @@ -311,9 +292,6 @@ export function useThreadActionMenu(input: { }, [ archiveThread, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, confirmThreadArchive, confirmThreadDelete, confirmAndUnpinThread, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 527081dcf7aca..64915228c779b 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -5,7 +5,7 @@ import { scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -64,18 +64,6 @@ export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass()( - "ThreadSettleBlockedError", - { - environmentId: EnvironmentId, - threadId: ThreadId, - }, -) { - override get message(): string { - return "This thread still needs attention. Resolve or interrupt it first, then try again."; - } -} - export class ThreadSnoozeUnsupportedError extends Schema.TaggedErrorClass()( "ThreadSnoozeUnsupportedError", { @@ -506,19 +494,6 @@ export function useThreadActions() { ); } const resolved = resolveThreadTarget(target); - // Settle may only target what effectiveSettled could classify as - // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would hide live work. - if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) { - return AsyncResult.failure( - Cause.fail( - new ThreadSettleBlockedError({ - environmentId: resolved.threadRef.environmentId, - threadId: resolved.threadRef.threadId, - }), - ), - ); - } const wokeAt = resolved ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) : null; diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 6c5a61cd7d01b..3761cbaf87b39 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -88,17 +88,28 @@ A turn is complete when its session leaves `running` status, projected by `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later does not define turn end. +Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement. +[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and +once per minute, including when no client is connected. It dispatches the guarded internal +`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic +settlement excludes live background work and requires a comparable PR timestamp for immediate PR +settlement. The command also rejects any later event for its thread after the reactor's snapshot. +Clients render the persisted settlement state and do not derive settlement from PR or inactivity +state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider +session. + ## Drainable workers Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]: [`ProviderRuntimeIngestion`][ingest] normalizes provider runtime streams into orchestration commands, -[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, and -[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints. +[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, +[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints, and +[`ThreadSettlementReactor`][settlement] evaluates server-owned automatic settlement rules. `DrainableWorker` pairs a transactional queue with a transactional count of outstanding items. `enqueue` atomically offers and increments; processing always decrements. `drain` retries until the count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping. -Each of the three services exposes `drain` for exactly this. +Each of these four services exposes `drain` for exactly this. Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in [`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not @@ -150,5 +161,6 @@ already dispatch. [ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts [cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts [checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts +[settlement]: ../../apps/server/src/orchestration/ThreadSettlementReactor.ts [receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts [drivers]: ../../apps/server/src/provider/builtInDrivers.ts diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index f0fbfdccbd6c0..8240723999deb 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -10,6 +10,17 @@ confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p Pinned threads still move to **Settled** when they become inactive. They also move when their pull request merges if **Auto-settle merged threads** is enabled. +Each environment owns its automatic settlement settings. The server checks them even when no web, +desktop, or mobile client is connected. By default, it settles threads after three days without +activity and when their pull request merges. An eligible idle thread also settles when its pull +request closes. An open pull request blocks inactivity settlement. Active work, pending input, and +live background work keep the thread active. T3 Code settles from a closed or merged pull request +only when its timestamp is not older than the user's latest activity. If that timestamp is not +available, the inactivity rule still applies. A manual un-settle also keeps the thread active. +Change these rules in **Settings > General** for the environment. A settings change affects future +settlement and does not reopen a settled thread. Settings saved by older clients on one device no +longer control this behavior. + When you un-settle a thread, it returns to the top of the active list so you can find it right away. Its timestamps do not change. Other threads keep their positions. diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts deleted file mode 100644 index 06a8bb32c7937..0000000000000 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationThreadShell, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - canSettle, - changeRequestAutoSettles, - effectiveSettled, - hasQueuedTurnStart, - threadLastActivityAt, - type ChangeRequestStateLike, -} from "./threadSettled.ts"; - -const NOW = "2026-04-10T00:00:00.000Z"; -const FRESH = "2026-04-09T00:00:00.000Z"; -const STALE = "2026-04-06T23:59:59.999Z"; - -describe("changeRequestAutoSettles", () => { - it.each([ - ["open", true, false], - ["merged", true, true], - ["merged", false, false], - ["closed", false, true], - [null, false, false], - ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => { - expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe( - expected, - ); - }); - - const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z"; - const idleThread = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: null, - latestTurn: null, - }; - - it("ignores a terminal change request last touched before the thread existed", () => { - for (const state of ["merged", "closed"] as const) { - expect( - changeRequestAutoSettles( - { state, updatedAt: "2026-03-31T23:59:59.999Z" }, - { thread: idleThread }, - ), - ).toBe(false); - } - }); - - it("settles on a terminal change request touched at or after the thread's latest event", () => { - for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) { - expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe( - true, - ); - } - }); - - it("never re-settles a thread revived after the merge", () => { - // Settling on a merge happens once: a user message newer than the PR's - // last activity means the conversation outlived the PR. - const revived = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: "2026-04-05T00:00:00.000Z", - latestTurn: null, - }; - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" }, - { thread: revived }, - ), - ).toBe(false); - // A merge landing after the revival still settles. - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" }, - { thread: revived }, - ), - ).toBe(true); - }); - - it("still settles when the merge lands during an in-flight turn", () => { - // Anchor is user-initiated activity only: the agent finishing a turn - // after the merge must not block the settle the merge earned. - const midTurnMerge = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: "2026-04-02T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-mid"), - state: "completed" as const, - requestedAt: "2026-04-02T00:00:00.000Z", - startedAt: "2026-04-02T00:00:05.000Z", - completedAt: "2026-04-02T00:20:00.000Z", - assistantMessageId: null, - }, - }; - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" }, - { thread: midTurnMerge }, - ), - ).toBe(true); - }); - - it("falls back to settling when either timestamp is missing or malformed", () => { - expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true); - expect( - changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }), - ).toBe(true); - expect( - changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}), - ).toBe(true); - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "not-a-date" }, - { thread: idleThread }, - ), - ).toBe(true); - }); -}); - -function makeShell(input: { - readonly settledOverride?: "settled" | "active" | null; - readonly activityAt: string | null; - readonly sessionStatus?: "starting" | "running"; - readonly pending?: "approval" | "user-input"; -}): OrchestrationThreadShell { - const threadId = ThreadId.make("thread-1"); - return { - id: threadId, - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: - input.activityAt === null - ? null - : { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: input.activityAt, - startedAt: null, - completedAt: null, - assistantMessageId: null, - }, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: NOW, - archivedAt: null, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledOverride === "settled" ? NOW : null, - session: - input.sessionStatus === undefined - ? null - : { - threadId, - status: input.sessionStatus, - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW, - }, - latestUserMessageAt: null, - hasPendingApprovals: input.pending === "approval", - hasPendingUserInput: input.pending === "user-input", - hasActionableProposedPlan: false, - }; -} - -describe("threadLastActivityAt", () => { - it("returns the latest real user or turn activity and ignores thread/session updates", () => { - const shell = makeShell({ activityAt: null, sessionStatus: "running" }); - const withActivity: OrchestrationThreadShell = { - ...shell, - latestUserMessageAt: "2026-04-04T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: "2026-04-03T00:00:00.000Z", - startedAt: "2026-04-05T00:00:00.000Z", - completedAt: "2026-04-06T00:00:00.000Z", - assistantMessageId: null, - }, - }; - - expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); - expect(threadLastActivityAt(shell)).toBeNull(); - }); -}); - -describe("effectiveSettled", () => { - const overrideCases = [null, "settled", "active"] as const; - const changeRequestStates = [undefined, "open", "merged"] as const; - const inactivityCases = [ - ["fresh", FRESH], - ["stale", STALE], - ["no-activity", null], - ] as const; - const runningCases = [false, true] as const; - const pendingCases = [undefined, "approval", "user-input"] as const; - const truthTable = overrideCases.flatMap((settledOverride) => - changeRequestStates.flatMap((changeRequestState) => - inactivityCases.flatMap(([inactivity, activityAt]) => - runningCases.flatMap((running) => - pendingCases.map((pending) => ({ - settledOverride, - changeRequestState, - inactivity, - activityAt, - running, - pending, - // Settled iff nothing blocks (pending work / live session) AND - // the override says settled, or (with no override) a merged PR - // or staleness auto-settles. The "active" pin suppresses both - // auto signals, and an open PR suppresses the inactivity path: - // a thread with a PR out for review is never done, however quiet. - expected: - pending === undefined && - !running && - (settledOverride === "settled" || - (settledOverride === null && - (changeRequestState === "merged" || - (changeRequestState !== "open" && inactivity === "stale")))), - })), - ), - ), - ), - ); - - it.each(truthTable)( - "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", - ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { - const shell = makeShell({ - settledOverride, - activityAt, - ...(running ? { sessionStatus: "running" as const } : {}), - ...(pending === undefined ? {} : { pending }), - }); - const changeRequestOptions = - changeRequestState === undefined - ? {} - : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } }; - - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: 3, - ...changeRequestOptions, - }), - ).toBe(expected); - }, - ); - - it("treats closed change requests like merged ones", () => { - const shell = makeShell({ activityAt: null }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "closed" }, - }), - ).toBe(true); - }); - - it("settles immediately when a change request merges or closes", () => { - const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); - for (const changeRequestState of ["merged", "closed"] as const) { - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: changeRequestState }, - }), - ).toBe(true); - } - }); - - it("ignores a change request that merged before the thread's latest event", () => { - // A new thread started at a worktree root inherits the branch's old - // merged PR, and a revived thread outlives its merge; neither settles - // the live conversation. - const fresh = makeShell({ activityAt: FRESH }); - for (const state of ["merged", "closed"] as const) { - expect( - effectiveSettled(fresh, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" }, - }), - ).toBe(false); - } - // A merge during the thread's life still settles it. - expect( - effectiveSettled(fresh, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" }, - }), - ).toBe(true); - }); - - it("can keep a merged change request active", () => { - const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequest: { state: "closed" }, - }), - ).toBe(true); - }); - - it("never auto-settles a stale thread with an open change request", () => { - const stale = makeShell({ activityAt: STALE }); - expect( - effectiveSettled(stale, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "open" }, - }), - ).toBe(false); - // An explicit user settle still wins: open PR only blocks the auto path. - const settled = makeShell({ settledOverride: "settled", activityAt: STALE }); - expect( - effectiveSettled(settled, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "open" }, - }), - ).toBe(true); - }); - - it("keeps an explicitly un-settled merged-PR thread active", () => { - const shell = makeShell({ - settledOverride: "active", - activityAt: "2026-04-09T23:59:59.999Z", - }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - }); - - it("never settles a starting session, even with a settled override", () => { - const shell = makeShell({ - settledOverride: "settled", - activityAt: STALE, - sessionStatus: "starting", - }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - }); - - it("keeps a new turn active from queued through starting and running", () => { - const requestedAt = "2026-04-09T12:00:00.000Z"; - const transitionNow = "2026-04-09T12:00:30.000Z"; - const base = makeShell({ - settledOverride: null, - activityAt: STALE, - }); - const queued: OrchestrationThreadShell = { - ...base, - latestUserMessageAt: requestedAt, - latestTurn: null, - session: null, - }; - const starting: OrchestrationThreadShell = { - ...queued, - session: { - threadId: queued.id, - status: "starting", - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: requestedAt, - }, - }; - const running: OrchestrationThreadShell = { - ...starting, - session: { - ...starting.session!, - status: "running", - activeTurnId: TurnId.make("turn-new"), - }, - }; - - for (const shell of [queued, starting, running]) { - expect( - effectiveSettled(shell, { - now: transitionNow, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - } - }); - - it("uses a strict inactivity boundary and honors a null threshold", () => { - const boundary = makeShell({ - activityAt: "2026-04-07T00:00:00.000Z", - }); - const stale = makeShell({ activityAt: STALE }); - - expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); - expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); - }); -}); - -describe("hasQueuedTurnStart", () => { - const QUEUED_AT = "2026-04-09T12:00:00.000Z"; - // Within the adoption grace window of the queued message. - const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" }; - - it("flags a user message no turn has picked up, within the grace window", () => { - const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; - expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true); - - const staleTurn = { - ...makeShell({ activityAt: FRESH }), - latestUserMessageAt: QUEUED_AT, - }; - expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true); - }); - - it("expires after the grace window: an unadopted message is a failed start, not queued work", () => { - const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; - expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false); - // Historical shells (e.g. from servers that never carried latestTurn) - // must never read as queued. - expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false); - }); - - it("clears once a turn adopts the message or the start fails", () => { - const adopted = { - ...makeShell({ activityAt: QUEUED_AT }), - latestUserMessageAt: QUEUED_AT, - }; - expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false); - - const failed = makeShell({ activityAt: FRESH }); - const failedShell = { - ...failed, - latestUserMessageAt: QUEUED_AT, - session: { - threadId: failed.id, - status: "error" as const, - providerName: "Codex", - runtimeMode: "full-access" as const, - activeTurnId: null, - lastError: "boom", - updatedAt: NOW, - }, - }; - expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false); - }); - - it("is quiet without user messages", () => { - expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false); - }); - - it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => { - // Message timestamps originate on other devices; a clock an hour ahead - // must not hold the queued state for the whole skew. - const skewed = { - latestUserMessageAt: "2026-04-09T13:00:00.000Z", - latestTurn: null, - session: null, - }; - expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false); - // A small negative age (within the grace window) still reads as queued. - const slightlyAhead = { - latestUserMessageAt: "2026-04-09T12:00:30.000Z", - latestTurn: null, - session: null, - }; - expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true); - }); -}); - -describe("canSettle", () => { - it("blocks every state effectiveSettled refuses to classify as settled", () => { - expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true); - expect( - canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }), - ).toBe(false); - expect( - canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }), - ).toBe(false); - expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe( - false, - ); - expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe( - false, - ); - }); - - it("blocks settling a queued turn start, only within the grace window", () => { - const queued = { - ...makeShell({ activityAt: FRESH }), - latestUserMessageAt: "2026-04-09T12:00:00.000Z", - }; - const justAfter = "2026-04-09T12:00:30.000Z"; - expect(canSettle(queued, { now: justAfter })).toBe(false); - // effectiveSettled must agree: queued work never auto-settles either, - // even with a merged PR. - expect( - effectiveSettled(queued, { - now: justAfter, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - // Past the window the message is a failed/stale start: settleable again. - expect(canSettle(queued, { now: NOW })).toBe(true); - }); - - it("lets a server-accepted settle overrule the clock-derived queued blocker", () => { - // The settle action ran with wall-clock `now` (past the grace window); - // the list partition re-evaluates with a minute-floored `now` that is - // still INSIDE the window. settledAt >= message time proves the server - // already adjudicated this exact message, so the row must not snap back - // to active until the coarser clock catches up. - const messageAt = "2026-04-09T12:00:00.000Z"; - const flooredNow = "2026-04-09T12:01:00.000Z"; - const base = makeShell({ settledOverride: "settled", activityAt: null }); - const settledAfterMessage = { - ...base, - latestUserMessageAt: messageAt, - settledAt: "2026-04-09T12:02:10.000Z", - }; - expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true); - expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe( - true, - ); - - // A message NEWER than settledAt is genuinely new work: still blocked - // until the server's auto-unsettle lands. - const messageAfterSettle = { - ...base, - latestUserMessageAt: "2026-04-09T12:03:00.000Z", - settledAt: "2026-04-09T12:02:10.000Z", - }; - expect( - effectiveSettled(messageAfterSettle, { - now: "2026-04-09T12:03:30.000Z", - autoSettleAfterDays: 3, - }), - ).toBe(false); - }); - - it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { - // Anything canSettle rejects must render as active even when the user - // settled it earlier. - const blocked = makeShell({ - settledOverride: "settled", - activityAt: FRESH, - pending: "user-input", - }); - expect(canSettle(blocked, { now: NOW })).toBe(false); - expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); - }); -}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index eb965c6afe2a2..f5209a09e4998 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,100 +1,6 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. import type { OrchestrationThreadShell } from "@t3tools/contracts"; -export type ChangeRequestStateLike = "open" | "closed" | "merged"; - -/** - * The slice of a change request the settle rules need. `updatedAt` is the - * provider's last-activity timestamp; for a merged/closed request it bounds - * when the terminal state landed. - */ -export interface ChangeRequestSettleSource { - readonly state: ChangeRequestStateLike; - readonly updatedAt?: string | null | undefined; -} - -/** What the settle rules need to know about the thread's own timeline. */ -export type ThreadActivitySource = Pick< - OrchestrationThreadShell, - "createdAt" | "latestUserMessageAt" | "latestTurn" ->; - -/** - * Latest USER-initiated activity: messages and the turn requests they start, - * deliberately not the agent-side started/completed stamps. The settle-on- - * merge anchor uses this so a merge landing mid-turn still settles the - * thread when that turn finishes, while a user re-engaging after the merge - * blocks it for good. Falls back to creation time for untouched threads. - */ -function threadUserActivityAnchorAt(thread: ThreadActivitySource): string { - const messageAt = thread.latestUserMessageAt; - const requestedAt = thread.latestTurn?.requestedAt; - let anchor = thread.createdAt; - for (const candidate of [messageAt, requestedAt]) { - if (candidate != null && Date.parse(candidate) > Date.parse(anchor)) { - anchor = candidate; - } - } - return anchor; -} - -/** - * Returns whether the change request settles the thread immediately. A - * terminal request settles the thread only while it postdates every user- - * initiated event in it: settling on a merge happens ONCE. A request last - * touched before the thread was created is inherited branch history (a new - * thread started at a worktree root whose PR already merged), and one older - * than the user's latest engagement was already adjudicated — re-engaging a - * thread whose PR merged is the user saying the conversation outlived the - * PR. Unknown timestamps keep the old always-settle behavior. - */ -export function changeRequestAutoSettles( - changeRequest: ChangeRequestSettleSource | null | undefined, - options: { - readonly autoSettleOnMerge?: boolean | undefined; - readonly thread?: ThreadActivitySource | null | undefined; - } = {}, -): boolean { - if (changeRequest == null) return false; - const terminal = - changeRequest.state === "closed" || - (changeRequest.state === "merged" && options.autoSettleOnMerge !== false); - if (!terminal) return false; - if (changeRequest.updatedAt == null || options.thread == null) return true; - const updatedAtMs = Date.parse(changeRequest.updatedAt); - const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread)); - // Malformed timestamps fall back to settling, matching servers that never - // report updatedAt. - if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true; - return updatedAtMs >= anchorAtMs; -} - -const DAY_MS = 24 * 60 * 60 * 1_000; - -export function threadLastActivityAt( - shell: Pick, -): string | null { - const candidates = [ - shell.latestUserMessageAt, - shell.latestTurn?.requestedAt, - shell.latestTurn?.startedAt, - shell.latestTurn?.completedAt, - ]; - let latest: string | null = null; - let latestTimestamp = Number.NEGATIVE_INFINITY; - - for (const candidate of candidates) { - if (candidate === null || candidate === undefined) continue; - const timestamp = Date.parse(candidate); - if (timestamp > latestTimestamp) { - latest = candidate; - latestTimestamp = timestamp; - } - } - - return latest; -} - /** * A queued turn start lives for at most this long: session adoption takes * seconds, so a user message still unadopted after the grace window is a @@ -103,6 +9,7 @@ export function threadLastActivityAt( * such threads would be permanently unsettleable. */ export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; +const DAY_MS = 24 * 60 * 60 * 1_000; /** * A user message no turn has picked up yet: the turn.start command was @@ -137,28 +44,6 @@ export function hasQueuedTurnStart( ); } -/** - * A thread may be settled only when none of effectiveSettled's activity - * blockers hold. This is deliberately the same list: anything the partition - * refuses to CLASSIFY as settled must also be refused as a settle TARGET. - * The server enforces its own invariants; this client-side twin exists so - * the UI can disable/reject before a round trip. - */ -export function canSettle( - shell: Pick< - OrchestrationThreadShell, - "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" - >, - options: { readonly now: string }, -): boolean { - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - if (shell.session?.status === "starting" || shell.session?.status === "running") return false; - // Queued work is as blocked-on-progress as a live session: settling it - // (or auto-settling it on a closed PR) would hide a just-requested turn. - if (hasQueuedTurnStart(shell, options)) return false; - return true; -} - /** * The snooze lifecycle fields plus everything needed to detect a raised * hand. Snooze is an overlay on the active state: a snoozed thread stays @@ -181,8 +66,7 @@ export type ThreadSnoozeShell = Pick< * the session failed, or a run completed after the snooze was set — the * v1 taste of event-based snooze ("something happened" wakes early). * Raising a hand never clears the server-side snooze fields; it only stops - * the thread from CLASSIFYING as snoozed, exactly like blocked work and - * effectiveSettled. + * the thread from classifying as snoozed. */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; @@ -283,79 +167,6 @@ export function threadWokeAt( return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; } -/** - * Settled resolution over the server-backed settled lifecycle. Activity - * blockers (pending approval/user-input, a live session, an unadjudicated - * queued turn) are checked first and hold a thread active regardless of any - * override. Past the blockers, the explicit user override (thread.settle / - * thread.unsettle commands, projected into settledOverride + settledAt) - * wins in both directions; without one, a thread can auto-settle on a - * merged PR or always on a closed PR (both only while the terminal state is - * the thread's latest event, see changeRequestAutoSettles), or settles on - * inactivity past the window. - * An open PR blocks the inactivity path entirely. The server - * un-settles on real activity (user message, session start, approval/ - * user-input request), so an override never goes stale silently. - */ -export function effectiveSettled( - shell: OrchestrationThreadShell, - options: { - readonly now: string; - readonly autoSettleAfterDays: number | null; - readonly autoSettleOnMerge?: boolean; - readonly changeRequest?: ChangeRequestSettleSource | null; - }, -): boolean { - // Blocked work must remain visible even when a user explicitly settled it. - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - if (shell.session?.status === "starting" || shell.session?.status === "running") return false; - if (hasQueuedTurnStart(shell, { now: options.now })) { - // The queued-turn blocker alone is forgivable: it is clock-derived, and - // list callers pass a coarser `now` than the settle action used. When - // the server already adjudicated the queued message by accepting a - // settle after it (settledAt stamps server accept time), trust that - // ruling — otherwise a settle near the grace boundary leaves the row - // pinned active until the caller's clock ticks over. A message NEWER - // than settledAt is genuinely new work and keeps the block until the - // server's auto-unsettle lands. - const serverAdjudicated = - shell.settledOverride === "settled" && - shell.settledAt !== null && - shell.latestUserMessageAt !== null && - Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt); - if (!serverAdjudicated) return false; - } - if (shell.settledOverride === "settled") return true; - // "active" is the explicit keep-active pin: it suppresses auto-settle - // until real activity clears it server-side. - if (shell.settledOverride === "active") return false; - if ( - changeRequestAutoSettles(options.changeRequest, { - autoSettleOnMerge: options.autoSettleOnMerge, - thread: shell, - }) - ) { - return true; - } - // An open PR is unfinished business regardless of how long the thread has - // been quiet: review can take days, and hiding the thread would bury the - // work waiting on it. A configured merge, a close, or an explicit user - // settle resolves it. - if (options.changeRequest?.state === "open") return false; - if (options.autoSettleAfterDays === null) return false; - - const lastActivityAt = threadLastActivityAt(shell); - if (lastActivityAt === null) return false; - - // threadLastActivityAt only returns candidates whose Date.parse beat - // -Infinity, so this parse is a real number; a malformed `now` yields NaN, - // the comparison is false, and the thread stays active (never a surprise - // auto-settle on bad input). - return ( - Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS - ); -} - const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index ab4c7fb0784f4..8a62103950bfd 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -6,12 +6,14 @@ import { describe, expect, it } from "vite-plus/test"; import { canSnooze, effectiveSnoozed, + hasQueuedTurnStart, resolveSnoozePresets, snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, type ThreadSnoozeShell, } from "./threadSettled.ts"; +import type { OrchestrationThreadShell } from "@t3tools/contracts"; const NOW = "2026-04-10T12:00:00.000Z"; const SNOOZED_AT = "2026-04-10T09:00:00.000Z"; @@ -61,6 +63,15 @@ function makeShell(input: { }; } +type QueuedTurnShell = Pick< + OrchestrationThreadShell, + "latestUserMessageAt" | "latestTurn" | "session" +>; + +function makeQueuedTurnShell(overrides: Partial = {}): QueuedTurnShell { + return { latestUserMessageAt: null, latestTurn: null, session: null, ...overrides }; +} + describe("effectiveSnoozed", () => { it("hides a thread whose wake time is in the future", () => { expect(effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(true); @@ -202,6 +213,55 @@ describe("canSnooze", () => { }); }); +describe("hasQueuedTurnStart", () => { + it("expires queued state after two minutes", () => { + const thread = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T11:57:59.000Z", + }); + expect(hasQueuedTurnStart(thread, { now: NOW })).toBe(false); + }); + + it("clears queued state when a turn adopts the message or the session fails", () => { + const messageAt = "2026-04-10T11:59:00.000Z"; + const adopted = makeQueuedTurnShell({ + latestUserMessageAt: messageAt, + latestTurn: { + turnId: TurnId.make("turn-adopted"), + state: "running", + requestedAt: messageAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + }); + const failed = makeQueuedTurnShell({ + latestUserMessageAt: messageAt, + session: { + threadId: ThreadId.make("thread-failed"), + status: "error", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "failed", + updatedAt: NOW, + }, + }); + expect(hasQueuedTurnStart(adopted, { now: NOW })).toBe(false); + expect(hasQueuedTurnStart(failed, { now: NOW })).toBe(false); + }); + + it("bounds future client clock skew", () => { + const farAhead = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T12:03:00.000Z", + }); + const slightlyAhead = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T12:01:00.000Z", + }); + expect(hasQueuedTurnStart(farAhead, { now: NOW })).toBe(false); + expect(hasQueuedTurnStart(slightlyAhead, { now: NOW })).toBe(true); + }); +}); + describe("threadWokeAt", () => { it("is null for never-snoozed and still-snoozed threads", () => { expect(threadWokeAt(makeShell({}), { now: NOW })).toBe(null); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 805e9da23ebbc..08e82599020a8 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -63,6 +63,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ threadSettlement: Schema.optionalKey(Schema.Boolean), + /** Server evaluates merge and inactivity settlement without a client. */ + threadAutoSettlement: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7ca7175ad175d..bbfaa1b595c21 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -772,6 +772,13 @@ const ThreadSettleCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadAutoSettleCommand = Schema.Struct({ + type: Schema.Literal("thread.auto-settle"), + commandId: CommandId, + threadId: ThreadId, + snapshotSequence: NonNegativeInt, +}); + const ThreadUnsettleCommand = Schema.Struct({ type: Schema.Literal("thread.unsettle"), commandId: CommandId, @@ -1107,6 +1114,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ }); const InternalOrchestrationCommand = Schema.Union([ + ThreadAutoSettleCommand, ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e94a66435b1f0..fa1c702b19002 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -137,11 +137,8 @@ describe("ClientSettings environment identification", () => { }); describe("ClientSettings sidebar", () => { - it("defaults to the current sidebar with automatic merge and inactivity settling", () => { - const settings = decodeClientSettings({}); - expect(settings.legacySidebarEnabled).toBe(false); - expect(settings.sidebarAutoSettleAfterDays).toBe(3); - expect(settings.sidebarAutoSettleOnMerge).toBe(true); + it("defaults to the current sidebar", () => { + expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -166,25 +163,33 @@ describe("ClientSettings sidebar", () => { expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true); expect(() => decodeClientSettingsPatch({ confirmThreadUnpin: "yes" })).toThrow(); }); +}); - it("allows auto-settle by inactivity to be disabled", () => { - expect( - decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, - ).toBeNull(); +describe("ServerSettings thread settlement", () => { + it("defaults merge settlement on and inactivity settlement to three days", () => { + const settings = decodeServerSettings({}); + expect(settings.sidebarAutoSettleAfterDays).toBe(3); + expect(settings.sidebarAutoSettleOnMerge).toBe(true); }); - it("allows auto-settle on merge to be disabled", () => { - expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe( - false, - ); + it("allows both automatic rules to be disabled", () => { expect( - decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge, - ).toBe(false); + decodeServerSettings({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }), + ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false }); + expect( + decodeServerSettingsPatch({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }), + ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false }); }); it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { - expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); - expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeServerSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeServerSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b721ce976d1d9..25577b752c638 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -253,10 +253,6 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), - ), - sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -658,6 +654,10 @@ export const ServerSettings = Schema.Struct({ * between a desktop window and a phone attached to the same server. */ enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), + ), + sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New // consumers should resolve `backgroundActivity` instead. @@ -883,6 +883,8 @@ export const ServerSettingsPatch = Schema.Struct({ enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ schemaVersion: Schema.optionalKey(Schema.Literal(1)), @@ -976,8 +978,6 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), - sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), - sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From 8b033de48247086c1b6c6968ce7cf33b358a1ec5 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 1 Sep 2026 12:44:36 +0200 Subject: [PATCH 015/136] fix(clients): dedupe skills in composer menus (#8043) --- .../threads/use-composer-command-menu.ts | 10 ++- apps/web/src/providerSkillSearch.test.ts | 13 ++++ apps/web/src/providerSkillSearch.ts | 7 +- .../client-runtime/src/providerSkills.test.ts | 69 +++++++++++++++++++ packages/client-runtime/src/providerSkills.ts | 18 ++++- 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 1043e5e39aad7..ac703a547fde9 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -9,6 +9,10 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; +import { + dedupeProviderSkillsByName, + getProviderSkillsForSlashMenu, +} from "@t3tools/client-runtime/providerSkills"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; @@ -130,7 +134,7 @@ export function useComposerCommandMenu({ }); } - const skillItems = (selectedProviderStatus?.skills ?? []) + const skillItems = getProviderSkillsForSlashMenu(selectedProviderStatus?.skills ?? [], true) .filter((skill) => matchesSlashSkillQuery(skill, q)) .map((skill) => ({ id: `skill:${skill.name}`, @@ -144,7 +148,9 @@ export function useComposerCommandMenu({ } if (trigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled); + const enabledSkills = dedupeProviderSkillsByName( + (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled), + ); const normalizedQuery = normalizeSearchQuery(trigger.query, { trimLeadingPattern: /^\$+/, }); diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts index dbd0304c5e0b6..133f65218756d 100644 --- a/apps/web/src/providerSkillSearch.test.ts +++ b/apps/web/src/providerSkillSearch.test.ts @@ -69,4 +69,17 @@ describe("searchProviderSkills", () => { "browser", ]); }); + + it("returns the first enabled definition for each skill name", () => { + const skills = [ + makeSkill({ name: "branch-audit", path: "/Users/matt/.codex/skills/branch-audit/SKILL.md" }), + makeSkill({ name: "browser" }), + makeSkill({ name: "branch-audit", path: "/Users/matt/.agents/skills/branch-audit/SKILL.md" }), + ]; + + expect(searchProviderSkills(skills, "").map((skill) => skill.path)).toEqual([ + "/Users/matt/.codex/skills/branch-audit/SKILL.md", + "/tmp/browser/SKILL.md", + ]); + }); }); diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts index c12bec1327a4e..964907365fb97 100644 --- a/apps/web/src/providerSkillSearch.ts +++ b/apps/web/src/providerSkillSearch.ts @@ -1,5 +1,8 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; -import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; +import { + dedupeProviderSkillsByName, + formatProviderSkillDisplayName, +} from "@t3tools/client-runtime/providerSkills"; import { insertRankedSearchResult, normalizeSearchQuery, @@ -70,7 +73,7 @@ export function searchProviderSkills( query: string, limit = Number.POSITIVE_INFINITY, ): ServerProviderSkill[] { - const enabledSkills = skills.filter((skill) => skill.enabled); + const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)); const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); if (!normalizedQuery) { diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 08c79f5d8718d..c49b0b6822934 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + dedupeProviderSkillsByName, formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, @@ -26,6 +27,31 @@ describe("formatProviderSkillDisplayName", () => { }); }); +describe("dedupeProviderSkillsByName", () => { + it("keeps the first resolved skill and preserves unrelated skill order", () => { + const firstSkill = { + name: "branch-audit", + path: "/Users/matt/.codex/skills/branch-audit/SKILL.md", + enabled: true, + }; + const otherSkill = { + name: "browser", + path: "/Users/matt/.agents/skills/browser/SKILL.md", + enabled: true, + }; + const duplicateSkill = { + name: "Branch-Audit", + path: "/Users/matt/.agents/skills/branch-audit/SKILL.md", + enabled: true, + }; + + expect(dedupeProviderSkillsByName([firstSkill, otherSkill, duplicateSkill])).toEqual([ + firstSkill, + otherSkill, + ]); + }); +}); + describe("getProviderSkillsForSlashMenu", () => { it("keeps the skill alias when the provider also exposes it as a slash command", () => { const askMatt = { @@ -37,6 +63,49 @@ describe("getProviderSkillsForSlashMenu", () => { "ask-matt", ]); }); + + it("shows one row when enabled skills share a name", () => { + const skills = [ + { + name: "babysit-pr", + path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md", + enabled: true, + }, + { + name: "browser", + path: "/Users/matt/.agents/skills/browser/SKILL.md", + enabled: true, + }, + { + name: "babysit-pr", + path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md", + enabled: true, + }, + ]; + + expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([ + "babysit-pr", + "browser", + ]); + }); + + it("keeps an enabled skill when a disabled duplicate appears first", () => { + const enabledSkill = { + name: "babysit-pr", + path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md", + enabled: true, + }; + const skills = [ + { + name: "babysit-pr", + path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md", + enabled: false, + }, + enabledSkill, + ]; + + expect(getProviderSkillsForSlashMenu(skills, true)).toEqual([enabledSkill]); + }); }); describe("getProviderSlashCommandsForSlashMenu", () => { diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index faab12799ba99..653c1dd854749 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -25,11 +25,27 @@ export function formatProviderSkillDisplayName( return titleCaseWords(skill.name); } +export function dedupeProviderSkillsByName( + skills: ReadonlyArray, +): ServerProviderSkill[] { + const seenNames = new Set(); + return skills.filter((skill) => { + const normalizedName = skill.name.trim().toLowerCase(); + if (seenNames.has(normalizedName)) { + return false; + } + seenNames.add(normalizedName); + return true; + }); +} + export function getProviderSkillsForSlashMenu( skills: ReadonlyArray, showSkillsInSlashMenu: boolean, ): ServerProviderSkill[] { - return showSkillsInSlashMenu ? skills.filter((skill) => skill.enabled) : []; + return showSkillsInSlashMenu + ? dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)) + : []; } export function getProviderSlashCommandsForSlashMenu( From 62d39bf00d5ddd83a9b36a81321fe9aa4d2502bb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 04:05:55 -0700 Subject: [PATCH 016/136] fix(server): stop OpenCode child sessions (#9005) --- .../provider/Layers/OpenCodeAdapter.test.ts | 341 +++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 195 ++++++++-- docs/user/providers-opencode.md | 9 + 3 files changed, 497 insertions(+), 48 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 0ba4b2d1ee3d3..d297360e6d349 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -69,6 +69,11 @@ const runtimeMock = { abortImplementation: null as | ((sessionID: string, signal?: AbortSignal) => Promise) | null, + sessionChildrenCalls: [] as string[], + sessionChildrenById: new Map>(), + sessionChildrenImplementation: null as + | ((sessionID: string) => Promise>) + | null, closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, messageCalls: [] as Array<{ sessionID: string; messageID: string }>, @@ -116,6 +121,9 @@ const runtimeMock = { this.state.abortCalls.length = 0; this.state.abortSignals.length = 0; this.state.abortImplementation = null; + this.state.sessionChildrenCalls.length = 0; + this.state.sessionChildrenById.clear(); + this.state.sessionChildrenImplementation = null; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; this.state.messageCalls.length = 0; @@ -252,6 +260,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { } await runtimeMock.state.abortImplementation?.(sessionID, options?.signal); }, + children: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.sessionChildrenCalls.push(sessionID); + return { + data: runtimeMock.state.sessionChildrenImplementation + ? await runtimeMock.state.sessionChildrenImplementation(sessionID) + : (runtimeMock.state.sessionChildrenById.get(sessionID) ?? []), + }; + }, status: async () => { runtimeMock.state.sessionStatusCalls += 1; if (runtimeMock.state.sessionStatusImplementation) { @@ -1129,6 +1145,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { it.effect("stops a configured-server session without trying to own server lifecycle", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; + const rootSessionId = "http://127.0.0.1:9999/session"; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_stop_child" }]); + runtimeMock.state.sessionChildrenById.set("ses_stop_child", [{ id: "ses_stop_grandchild" }]); yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-opencode"), @@ -1138,10 +1157,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* adapter.stopSession(asThreadId("thread-opencode")); NodeAssert.deepEqual(runtimeMock.state.startCalls, []); - NodeAssert.deepEqual( - runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), - true, - ); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, [ + rootSessionId, + "ses_stop_child", + "ses_stop_grandchild", + ]); }), ); @@ -2999,6 +3019,262 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("stops the full OpenCode child tree before it completes the interrupt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-tree"); + const parentAbortEvent = promiseWithResolvers(); + const markerEvent = promiseWithResolvers(); + const parentAbortStarted = promiseWithResolvers(); + const parentAbortRelease = promiseWithResolvers(); + const childAbortStarted = promiseWithResolvers(); + const childAbortRelease = promiseWithResolvers(); + const rootSessionId = "http://127.0.0.1:9999/session"; + runtimeMock.state.subscribedEvents = [parentAbortEvent.promise, markerEvent.promise]; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [ + { id: "ses_child_a" }, + { id: "ses_child_b" }, + ]); + runtimeMock.state.sessionChildrenById.set("ses_child_a", [{ id: "ses_grandchild" }]); + runtimeMock.state.sessionChildrenById.set("ses_unrelated", [{ id: "ses_unrelated_child" }]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === rootSessionId) { + parentAbortStarted.resolve(undefined); + await parentAbortRelease.promise; + } + if (sessionID === "ses_child_a") { + childAbortStarted.resolve(undefined); + await childAbortRelease.promise; + } + }; + + const markerFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "thread.metadata.updated", + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run child agents", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => parentAbortStarted.promise); + runtimeMock.state.sessionChildrenById.get(rootSessionId)?.push({ id: "ses_late_child" }); + parentAbortEvent.resolve({ + id: "evt-parent-aborted", + type: "session.error", + properties: { + sessionID: rootSessionId, + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + markerEvent.resolve({ + id: "evt-after-parent-abort", + type: "session.updated", + properties: { info: { id: rootSessionId, title: "Parent abort received" } }, + }); + yield* Fiber.join(markerFiber); + + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + yield* Effect.promise(() => childAbortStarted.promise); + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_unrelated"), false); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_unrelated_child"), false); + const sessionsDuringCleanup = yield* adapter.listSessions(); + const sessionDuringCleanup = sessionsDuringCleanup.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionDuringCleanup?.status, "running"); + NodeAssert.equal(sessionDuringCleanup?.activeTurnId, turn.turnId); + const nextTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start after every child stops", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + childAbortRelease.resolve(undefined); + parentAbortRelease.resolve(undefined); + const result = yield* Fiber.join(interruptFiber); + const nextTurn = yield* Fiber.join(nextTurnFiber); + NodeAssert.equal(result._tag, "Success"); + NodeAssert.notEqual(nextTurn.turnId, turn.turnId); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + NodeAssert.equal(runtimeMock.state.abortCalls[0], rootSessionId); + NodeAssert.deepEqual( + new Set(runtimeMock.state.abortCalls.slice(1)), + new Set(["ses_child_a", "ses_child_b", "ses_grandchild", "ses_late_child"]), + ); + NodeAssert.deepEqual( + new Set(runtimeMock.state.sessionChildrenCalls), + new Set([rootSessionId, "ses_child_a", "ses_child_b", "ses_grandchild", "ses_late_child"]), + ); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, nextTurn.turnId); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("limits SDK requests across the full OpenCode child tree", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-request-limit"); + const rootSessionId = "http://127.0.0.1:9999/session"; + const requestRelease = promiseWithResolvers(); + const limitReached = promiseWithResolvers(); + let inFlight = 0; + let maxInFlight = 0; + const holdRequest = async (result: T): Promise => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + if (inFlight === 8) { + limitReached.resolve(undefined); + } + await requestRelease.promise; + inFlight -= 1; + return result; + }; + + const children = Array.from({ length: 8 }, (_, index) => ({ id: `ses_child_${index}` })); + runtimeMock.state.sessionChildrenById.set(rootSessionId, children); + for (const child of children.slice(1)) { + runtimeMock.state.sessionChildrenById.set( + child.id, + Array.from({ length: 8 }, (_, index) => ({ id: `${child.id}_nested_${index}` })), + ); + } + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID.includes("_nested_")) { + await holdRequest(undefined); + } + }; + runtimeMock.state.sessionChildrenImplementation = async (sessionID) => { + if (sessionID === "ses_child_0") { + return await holdRequest([]); + } + return runtimeMock.state.sessionChildrenById.get(sessionID) ?? []; + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run a nested child tree", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => limitReached.promise); + yield* Effect.yieldNow; + + NodeAssert.equal(inFlight, 8); + NodeAssert.equal(maxInFlight, 8); + + requestRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + + runtimeMock.state.abortImplementation = null; + runtimeMock.state.sessionChildrenImplementation = null; + runtimeMock.state.sessionChildrenById.clear(); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("attempts every child abort and fails the interrupt when one child abort fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-failure"); + const rootSessionId = "http://127.0.0.1:9999/session"; + const failingChildStarted = promiseWithResolvers(); + const failingChildRelease = promiseWithResolvers(); + const siblingAbortStarted = promiseWithResolvers(); + runtimeMock.state.sessionChildrenById.set(rootSessionId, [ + { id: "ses_failing_child" }, + { id: "ses_surviving_sibling" }, + ]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === "ses_failing_child") { + failingChildStarted.resolve(undefined); + await failingChildRelease.promise; + throw new Error("child abort failed"); + } + if (sessionID === "ses_surviving_sibling") { + siblingAbortStarted.resolve(undefined); + } + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run child agents", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => failingChildStarted.promise); + yield* Effect.promise(() => siblingAbortStarted.promise); + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + failingChildRelease.resolve(undefined); + const result = yield* Fiber.join(interruptFiber); + + NodeAssert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + NodeAssert.equal(result.failure._tag, "ProviderAdapterRequestError"); + NodeAssert.equal(result.failure.detail, "child abort failed"); + } + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_failing_child"), true); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_surviving_sibling"), true); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, turn.turnId); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + it.effect("keeps an idle event from completing a turn while its abort request is pending", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -4278,11 +4554,20 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const threadId = asThreadId("thread-interrupt-provider-error"); const errorEvent = promiseWithResolvers(); const abortStarted = promiseWithResolvers(); - const abortRelease = promiseWithResolvers(); + const childAbortStarted = promiseWithResolvers(); + const childAbortRelease = promiseWithResolvers(); + const rootSessionId = "http://127.0.0.1:9999/session"; runtimeMock.state.subscribedEvents = [errorEvent.promise]; - runtimeMock.state.abortImplementation = async () => { - abortStarted.resolve(undefined); - await abortRelease.promise; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_error_child" }]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === rootSessionId) { + abortStarted.resolve(undefined); + await new Promise(() => {}); + } + if (sessionID === "ses_error_child") { + childAbortStarted.resolve(undefined); + await childAbortRelease.promise; + } }; const eventsFiber = yield* adapter.streamEvents.pipe( @@ -4313,16 +4598,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { id: "evt-provider-error-after-stop", type: "session.error", properties: { - sessionID: "http://127.0.0.1:9999/session", + sessionID: rootSessionId, error: { name: "APIError", data: { message: "Upstream failed", isRetryable: false }, }, }, }); - yield* Effect.yieldNow; - abortRelease.resolve(undefined); - yield* Fiber.join(interruptFiber); + yield* Effect.promise(() => childAbortStarted.promise); const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); NodeAssert.deepEqual( @@ -4341,7 +4624,41 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { failed?.type === "turn.completed" ? failed.payload.state : undefined, "failed", ); + const sessionsDuringCleanup = yield* adapter.listSessions(); + const sessionDuringCleanup = sessionsDuringCleanup.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionDuringCleanup?.status, "error"); + NodeAssert.equal(sessionDuringCleanup?.activeTurnId, undefined); + + const secondInterruptFiber = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild); + const nextTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start after child cleanup", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal( + runtimeMock.state.abortCalls.filter((sessionID) => sessionID === rootSessionId).length, + 1, + ); + NodeAssert.equal(secondInterruptFiber.pollUnsafe(), undefined); + NodeAssert.equal(nextTurnFiber.pollUnsafe(), undefined); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + childAbortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + yield* Fiber.join(secondInterruptFiber); + const nextTurn = yield* Fiber.join(nextTurnFiber); + NodeAssert.notEqual(nextTurn.turnId, turn.turnId); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + + runtimeMock.state.abortImplementation = null; yield* adapter.stopSession(threadId); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 606dcde6ce9eb..d0b4f0de78ce1 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -192,8 +192,10 @@ const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessio interface OpenCodeCancellation { readonly turnId: TurnId | undefined; + readonly acknowledgment: Deferred.Deferred; readonly completion: Deferred.Deferred; acknowledged?: boolean; + turnSettled?: boolean; deferredIdleEvent?: OpenCodeSessionStatusEvent; } @@ -702,10 +704,88 @@ const failPendingOpenCodeCancellation = Effect.fn("failPendingOpenCodeCancellati ).pipe(Effect.ignore); }); -const abortOpenCodeSessionForTeardown = (context: OpenCodeSessionContext) => - runOpenCodeSdk("session.abort", (signal) => +const abortOpenCodeDescendants = Effect.fn("abortOpenCodeDescendants")(function* ( + context: OpenCodeSessionContext, +) { + const visited = new Set([context.openCodeSessionId]); + const requestSemaphore = Semaphore.makeUnsafe(8); + + const visit = ( + sessionId: string, + abortSession: boolean, + ): Effect.Effect => + Effect.gen(function* () { + let firstFailure: OpenCodeRuntimeError | undefined; + if (abortSession) { + const abortResult = yield* requestSemaphore + .withPermit( + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort({ sessionID: sessionId }, { signal }), + ), + ) + .pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + Effect.result, + ); + if (abortResult._tag === "Failure") { + firstFailure = abortResult.failure; + } + } + + const childrenResult = yield* requestSemaphore + .withPermit( + runOpenCodeSdk("session.children", (signal) => + context.client.session.children({ sessionID: sessionId }, { signal }), + ), + ) + .pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + Effect.result, + ); + if (childrenResult._tag === "Failure") { + return firstFailure ?? childrenResult.failure; + } + + const children = childrenResult.success?.data ?? []; + const newChildren = children.filter((child) => { + if (visited.has(child.id)) { + return false; + } + visited.add(child.id); + return true; + }); + const childFailures = yield* Effect.forEach(newChildren, (child) => visit(child.id, true), { + concurrency: 8, + }); + firstFailure ??= childFailures.find((failure) => failure !== undefined); + return firstFailure; + }); + + const firstFailure = yield* visit(context.openCodeSessionId, false); + if (firstFailure) { + return yield* firstFailure; + } +}); + +const abortOpenCodeSessionForTeardown = Effect.fn("abortOpenCodeSessionForTeardown")(function* ( + context: OpenCodeSessionContext, +) { + // Stop the parent before the snapshot so it cannot add another child after + // the adapter reads the tree. + yield* runOpenCodeSdk("session.abort", (signal) => context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), ).pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })); + yield* abortOpenCodeDescendants(context).pipe( + Effect.timeout("1 second"), + Effect.ignore({ log: true }), + ); +}); const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(function* ( context: OpenCodeSessionContext, @@ -2158,13 +2238,12 @@ export function makeOpenCodeAdapter( if (isOpenCodeAbortError(event.properties.error)) { if (cancellation !== undefined && cancellation.turnId === undefined) { cancellation.acknowledged = true; - context.cancellation = undefined; - context.reconcileIdleStatus = true; - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore); break; } if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) { - yield* interruptOpenCodeTurn(context, activeTurnId, event); + cancellation.acknowledged = true; + yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore); break; } if (context.interruptedTurnId !== undefined || context.reconcileIdleStatus) { @@ -2172,9 +2251,13 @@ export function makeOpenCodeAdapter( } } yield* cancelIdleReconciliation(context); - if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) { - context.cancellation = undefined; - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + const terminalCancellation = + activeTurnId !== undefined && cancellation?.turnId === activeTurnId + ? cancellation + : undefined; + if (terminalCancellation) { + terminalCancellation.turnSettled = true; + terminalCancellation.acknowledged = true; } context.activeTurnId = undefined; context.activeAgent = undefined; @@ -2214,6 +2297,11 @@ export function makeOpenCodeAdapter( detail: event.properties.error, }, }); + if (terminalCancellation) { + yield* Deferred.succeed(terminalCancellation.acknowledgment, undefined).pipe( + Effect.ignore, + ); + } break; } @@ -2909,14 +2997,12 @@ export function makeOpenCodeAdapter( return; } const existingCancellation = context.cancellation; - if ( - existingCancellation !== undefined && - existingCancellation.turnId === interruptedTurnId - ) { + if (existingCancellation !== undefined) { return yield* Deferred.await(existingCancellation.completion); } const cancellation: OpenCodeCancellation = { turnId: interruptedTurnId, + acknowledgment: Deferred.makeUnsafe(), completion: Deferred.makeUnsafe(), }; context.cancellation = cancellation; @@ -2929,10 +3015,11 @@ export function makeOpenCodeAdapter( yield* Deferred.await(promptAdmission.submissionSettled); } - const abortOutcome = yield* Effect.raceFirst( + const parentAbortOutcome = yield* Effect.raceFirst( runOpenCodeSdk("session.abort", (signal) => context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), ).pipe( + Effect.asVoid, Effect.timeout("10 seconds"), Effect.catchTags({ OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), @@ -2949,33 +3036,67 @@ export function makeOpenCodeAdapter( Effect.exit, Effect.map((exit) => ({ source: "request" as const, exit })), ), + Effect.raceFirst( + Deferred.await(cancellation.acknowledgment).pipe( + Effect.map(() => ({ source: "acknowledgment" as const })), + ), + Deferred.await(cancellation.completion).pipe( + Effect.exit, + Effect.map((exit) => ({ source: "completion" as const, exit })), + ), + ), + ); + if (parentAbortOutcome.source === "completion") { + return Exit.isFailure(parentAbortOutcome.exit) + ? yield* Effect.failCause(parentAbortOutcome.exit.cause) + : undefined; + } + const parentAbortExit = + parentAbortOutcome.source === "request" ? parentAbortOutcome.exit : Exit.void; + + const descendantAbortOutcome = yield* Effect.raceFirst( + abortOpenCodeDescendants(context).pipe( + Effect.timeout("10 seconds"), + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), + TimeoutError: (cause) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: "OpenCode child session cleanup did not complete within 10 seconds.", + cause, + }), + ), + }), + Effect.exit, + Effect.map((exit) => ({ source: "request" as const, exit })), + ), Deferred.await(cancellation.completion).pipe( Effect.exit, - Effect.map((exit) => ({ source: "event" as const, exit })), + Effect.map((exit) => ({ source: "completion" as const, exit })), ), ); - if (abortOutcome.source === "event") { - return Exit.isFailure(abortOutcome.exit) - ? yield* Effect.failCause(abortOutcome.exit.cause) + if (descendantAbortOutcome.source === "completion") { + return Exit.isFailure(descendantAbortOutcome.exit) + ? yield* Effect.failCause(descendantAbortOutcome.exit.cause) : undefined; } - const abortExit = abortOutcome.exit; - if (Exit.isFailure(abortExit)) { - if (interruptedTurnId && context.interruptedTurnId === interruptedTurnId) { - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); - return; - } - if (cancellation.turnId === undefined && cancellation.acknowledged) { - if (context.cancellation === cancellation) { - context.cancellation = undefined; - context.reconcileIdleStatus = true; - } - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); - return; - } + + const parentAbortFailed = Exit.isFailure(parentAbortExit) && !cancellation.acknowledged; + const failedExit = parentAbortFailed + ? parentAbortExit + : Exit.isFailure(descendantAbortOutcome.exit) + ? descendantAbortOutcome.exit + : undefined; + if (failedExit !== undefined && Exit.isFailure(failedExit)) { if (context.cancellation === cancellation) { context.cancellation = undefined; - if (cancellation.turnId !== undefined && cancellation.deferredIdleEvent) { + if ( + parentAbortFailed && + cancellation.turnId !== undefined && + cancellation.deferredIdleEvent + ) { yield* scheduleIdleReconciliation( context, cancellation.turnId, @@ -2983,12 +3104,14 @@ export function makeOpenCodeAdapter( ); } } - yield* Deferred.done(cancellation.completion, abortExit).pipe(Effect.ignore); - return yield* Effect.failCause(abortExit.cause); + yield* Deferred.done(cancellation.completion, failedExit).pipe(Effect.ignore); + return yield* Effect.failCause(failedExit.cause); } if (context.cancellation === cancellation) { - if (cancellation.turnId !== undefined) { + if (cancellation.turnSettled) { + context.cancellation = undefined; + } else if (cancellation.turnId !== undefined) { yield* interruptOpenCodeTurn(context, cancellation.turnId); } else { context.cancellation = undefined; diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 09404bd34c064..a066b038833d1 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -17,6 +17,15 @@ With a server URL, T3 Code connects to that external server and uses only the pa provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server. OpenCode uses this password for HTTP Basic authentication. +## Stop a turn + +When you select **Stop**, T3 Code stops the main OpenCode session and all nested child sessions. +T3 Code waits for this cleanup before it marks the turn as stopped or sends the next prompt. It +does not stop unrelated OpenCode sessions. + +Stop reports an error if OpenCode cannot list or stop a child session. When T3 Code closes an +OpenCode session, it also tries to stop the child sessions, but this teardown is best effort. + ## Refresh the model list T3 Code loads the model list when an enabled OpenCode provider starts and keeps the list in its From 3c73fa7ce02b7ee6b2904ec58e47c06262aebf5d Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 1 Sep 2026 13:07:27 +0200 Subject: [PATCH 017/136] perf(web): defer pull request line stats until visible (#6471) Co-authored-by: Theo Browne --- .../components/pullRequest/PullRequestRow.tsx | 9 +- .../pullRequest/pullRequestList.logic.test.ts | 196 ++++++++++++++ .../pullRequest/pullRequestList.logic.ts | 150 +++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 243 ++++++++++++++---- apps/web/src/state/pullRequests.ts | 24 +- 5 files changed, 561 insertions(+), 61 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index b6b96ab7f55ed..2284144d7fd19 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,5 +1,5 @@ import { SearchIcon } from "lucide-react"; -import { memo } from "react"; +import { memo, type RefCallback } from "react"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; @@ -40,6 +40,8 @@ function PullRequestRowImpl({ showProvider, environmentLabel, matchedElsewhere, + statsKey, + statsRef, onSelect, }: { entry: EnvironmentPullRequestEntry; @@ -54,11 +56,16 @@ function PullRequestRowImpl({ * commit message. Saying so is the difference between a result and an apparently random row. */ matchedElsewhere?: boolean; + /** Used by the list's shared visibility observer to defer optional line-count reads. */ + statsKey?: string; + statsRef?: RefCallback; onSelect: (entry: EnvironmentPullRequestEntry) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); return (

) : null} - {group.entries.map((entry) => ( - 1 && - environmentLabels.get(entry.environmentId) !== undefined - ? { environmentLabel: environmentLabels.get(entry.environmentId)! } - : {})} - // Ten is the floor the ranking gives a row whose own fields say nothing - // about the search: the host matched something this row cannot show. - matchedElsewhere={ - typedParsed.text.length > 0 && - scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE - } - selected={ - selected?.environmentId === entry.environmentId && - selected.repository === entry.repository && - selected.number === entry.number - } - onSelect={selectEntry} - /> - ))} + {group.entries.map((entry) => { + const entryKey = pullRequestEntryKey(entry); + return ( + 1 && + environmentLabels.get(entry.environmentId) !== undefined + ? { environmentLabel: environmentLabels.get(entry.environmentId)! } + : {})} + // Ten is the floor the ranking gives a row whose own fields say nothing + // about the search: the host matched something this row cannot show. + matchedElsewhere={ + typedParsed.text.length > 0 && + scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE + } + selected={ + selected?.environmentId === entry.environmentId && + selected.repository === entry.repository && + selected.number === entry.number + } + onSelect={selectEntry} + /> + ); + })}
))}
@@ -1648,6 +1785,7 @@ function PullRequestsRouteView() { pullRequestsSupported && !rightPanelState.isOpen ? openPanelControls : null, rightPanelOpen: rightPanelState.isOpen, listBody, + scrollRef, }; const activateSurface = (surface: PullRequestSurface) => { @@ -1932,6 +2070,7 @@ function PullRequestsColumn({ titlebarControls, rightPanelOpen, listBody, + scrollRef, }: { refreshing: boolean; onRefresh: () => void; @@ -1950,8 +2089,8 @@ function PullRequestsColumn({ titlebarControls: ReactNode; rightPanelOpen: boolean; listBody: ReactNode; + scrollRef: RefObject; }) { - const scrollRef = useRef(null); const markerRef = useRef(null); const [condensed, setCondensed] = useState(false); useEffect(() => { diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 99bbddd6e3880..939c4f3aea2d7 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -31,7 +31,7 @@ export interface EnvironmentQueryTarget { } interface MergedEnvironmentQueryView
{ - /** One entry per environment that has answered, in the order the targets were given. */ + /** One entry per query target that has answered, in the order the targets were given. */ readonly values: ReadonlyArray; /** The first environment that failed. Others may still have answered — this is not fatal. */ readonly error: string | null; @@ -78,11 +78,16 @@ function createMergedEnvironmentQuery( return function useMergedQuery(targets: ReadonlyArray>) { const key = JSON.stringify(targets); const view = useAtomValue(targets.length === 0 ? empty : family(key)); - const refresh = useCallback(() => { - for (const target of JSON.parse(key) as ReadonlyArray>) { - appAtomRegistry.refresh(atomFor(target)); - } - }, [key]); + const refresh = useCallback( + (override?: ReadonlyArray>) => { + const refreshTargets = + override ?? (JSON.parse(key) as ReadonlyArray>); + for (const target of refreshTargets) { + appAtomRegistry.refresh(atomFor(target)); + } + }, + [key], + ); return { ...view, refresh }; }; } @@ -118,7 +123,10 @@ export function usePullRequestListStats( targets: ReadonlyArray>, ): { readonly stats: ReadonlyArray | null; - readonly refresh: () => void; + readonly isPending: boolean; + readonly refresh: ( + targets?: ReadonlyArray>, + ) => void; } { const query = usePullRequestStatsQuery(targets); const stats = useMemo( @@ -130,5 +138,5 @@ export function usePullRequestListStats( ), [query.values], ); - return { stats, refresh: query.refresh }; + return { stats, isPending: query.isPending, refresh: query.refresh }; } From e86604d3372acccd9f6a33a2c4ae46f4e2685541 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 04:09:06 -0700 Subject: [PATCH 018/136] perf(server): skip full-message reads while streaming (#9032) --- .../Layers/ProjectionPipeline.test.ts | 59 +++++++++++++++-- .../Layers/ProjectionPipeline.ts | 33 +++++++--- .../Layers/ProjectionThreadMessages.test.ts | 65 +++++++++++++++++++ .../Layers/ProjectionThreadMessages.ts | 53 +++++++++++++++ .../Services/ProjectionThreadMessages.ts | 12 ++++ 5 files changed, 205 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index b8f65a54b92ef..32551643b0d45 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1462,6 +1462,8 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; const now = "2026-01-01T00:00:00.000Z"; + const streamingAt = "2026-01-01T00:00:01.000Z"; + const completedAt = "2026-01-01T00:00:02.000Z"; yield* eventStore.append({ type: "project.created", @@ -1526,7 +1528,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { role: "assistant", text: "hello", turnId: null, - streaming: false, + streaming: true, createdAt: now, updatedAt: now, }, @@ -1539,7 +1541,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { eventId: EventId.make("evt-a4"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-a"), - occurredAt: now, + occurredAt: streamingAt, commandId: CommandId.make("cmd-a4"), causationEventId: null, correlationId: CorrelationId.make("cmd-a4"), @@ -1551,18 +1553,61 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { text: " world", turnId: null, streaming: true, - createdAt: now, - updatedAt: now, + createdAt: streamingAt, + updatedAt: streamingAt, }, }); yield* projectionPipeline.bootstrap; yield* projectionPipeline.bootstrap; - const messageRows = yield* sql<{ readonly text: string }>` - SELECT text FROM projection_thread_messages WHERE message_id = 'message-a' + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-a5"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-a"), + occurredAt: completedAt, + commandId: CommandId.make("cmd-a5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-a5"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-a"), + messageId: MessageId.make("message-a"), + role: "assistant", + text: "", + turnId: null, + streaming: false, + createdAt: completedAt, + updatedAt: completedAt, + }, + }); + + yield* projectionPipeline.bootstrap; + yield* projectionPipeline.bootstrap; + + const messageRows = yield* sql<{ + readonly text: string; + readonly isStreaming: number; + readonly createdAt: string; + readonly updatedAt: string; + }>` + SELECT + text, + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE message_id = 'message-a' `; - assert.deepEqual(messageRows, [{ text: "hello world" }]); + assert.deepEqual(messageRows, [ + { + text: "hello world", + isStreaming: 0, + createdAt: now, + updatedAt: completedAt, + }, + ]); const stateRows = yield* sql<{ readonly projector: string; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 672336816727f..22daeee693654 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1008,21 +1008,34 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; case "thread.message-sent": { + if (event.payload.streaming) { + const attachments = + event.payload.attachments !== undefined + ? yield* materializeAttachmentsForProjection({ + attachments: event.payload.attachments, + }) + : undefined; + yield* projectionThreadMessageRepository.appendStreaming({ + messageId: event.payload.messageId, + threadId: event.payload.threadId, + turnId: event.payload.turnId, + role: event.payload.role, + text: event.payload.text, + ...(attachments !== undefined ? { attachments: [...attachments] } : {}), + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ messageId: event.payload.messageId, }); const previousMessage = Option.getOrUndefined(existingMessage); const nextText = Option.match(existingMessage, { onNone: () => event.payload.text, - onSome: (message) => { - if (event.payload.streaming) { - return `${message.text}${event.payload.text}`; - } - if (event.payload.text.length === 0) { - return message.text; - } - return event.payload.text; - }, + onSome: (message) => + event.payload.text.length === 0 ? message.text : event.payload.text, }); const nextAttachments = event.payload.attachments !== undefined @@ -1037,7 +1050,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti role: event.payload.role, text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), - isStreaming: event.payload.streaming, + isStreaming: false, createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index b1f394a9e577f..30e0f42cab89e 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,6 +12,71 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { + it.effect("appends streaming text and applies attachment updates", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-streaming-append"); + const messageId = MessageId.make("message-streaming-append"); + const createdAt = "2026-02-28T19:05:00.000Z"; + const attachments = [ + { + type: "image" as const, + id: "thread-streaming-append-att-1", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]; + + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "hello", + attachments, + createdAt, + updatedAt: createdAt, + }); + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: " world", + createdAt: "2026-02-28T19:05:01.000Z", + updatedAt: "2026-02-28T19:05:01.000Z", + }); + + const rowWithPreservedAttachments = yield* repository.getByMessageId({ messageId }); + assert.equal(rowWithPreservedAttachments._tag, "Some"); + if (rowWithPreservedAttachments._tag === "Some") { + assert.deepEqual(rowWithPreservedAttachments.value.attachments, attachments); + } + + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "", + attachments: [], + createdAt: "2026-02-28T19:05:02.000Z", + updatedAt: "2026-02-28T19:05:02.000Z", + }); + + const row = yield* repository.getByMessageId({ messageId }); + assert.equal(row._tag, "Some"); + if (row._tag === "Some") { + assert.equal(row.value.text, "hello world"); + assert.deepEqual(row.value.attachments, []); + assert.equal(row.value.createdAt, createdAt); + assert.equal(row.value.updatedAt, "2026-02-28T19:05:02.000Z"); + assert.isTrue(row.value.isStreaming); + } + }), + ); + it.effect("preserves existing attachments when upsert omits attachments", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 7191916688693..85e854dc66063 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -9,6 +9,7 @@ import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { + AppendStreamingProjectionThreadMessage, GetProjectionThreadMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, @@ -95,6 +96,50 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { }, }); + const appendStreamingProjectionThreadMessageRow = SqlSchema.void({ + Request: AppendStreamingProjectionThreadMessage, + execute: (row) => { + const nextAttachmentsJson = + row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + return sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${row.messageId}, + ${row.threadId}, + ${row.turnId}, + ${row.role}, + ${row.text}, + ${nextAttachmentsJson}, + 1, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (message_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + role = excluded.role, + text = projection_thread_messages.text || excluded.text, + attachments_json = COALESCE( + excluded.attachments_json, + projection_thread_messages.attachments_json + ), + is_streaming = 1, + updated_at = excluded.updated_at + `; + }, + }); + const getProjectionThreadMessageRow = SqlSchema.findOneOption({ Request: GetProjectionThreadMessageInput, Result: ProjectionThreadMessageDbRowSchema, @@ -151,6 +196,13 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), ); + const appendStreaming: ProjectionThreadMessageRepositoryShape["appendStreaming"] = (row) => + appendStreamingProjectionThreadMessageRow(row).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.appendStreaming:query"), + ), + ); + const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) => getProjectionThreadMessageRow(input).pipe( Effect.mapError( @@ -176,6 +228,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { return { upsert, + appendStreaming, getByMessageId, listByThreadId, deleteByThreadId, diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff32025639..17b659a2f8dab 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; +import * as Struct from "effect/Struct"; import type * as Option from "effect/Option"; import type * as Effect from "effect/Effect"; @@ -34,6 +35,12 @@ export const ProjectionThreadMessage = Schema.Struct({ }); export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; +export const AppendStreamingProjectionThreadMessage = Schema.Struct( + Struct.omit(ProjectionThreadMessage.fields, ["isStreaming"]), +); +export type AppendStreamingProjectionThreadMessage = + typeof AppendStreamingProjectionThreadMessage.Type; + export const ListProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -62,6 +69,11 @@ export interface ProjectionThreadMessageRepositoryShape { message: ProjectionThreadMessage, ) => Effect.Effect; + /** Insert a streaming message or append text to its existing row. */ + readonly appendStreaming: ( + message: AppendStreamingProjectionThreadMessage, + ) => Effect.Effect; + /** * Read a projected thread message by id. */ From b883fc066ea5c9bebbe1c3e9b4bc2471aab3685f Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 1 Sep 2026 13:23:50 +0200 Subject: [PATCH 019/136] perf(client-runtime): halve server config bootstrap traffic (#8367) Reuse one server config subscription for session bootstrap and live updates. Preserve environment theme opt-in, replay, deletion, slow subscriber recovery, and config stream failure handling. Co-authored-by: Adamulek123 --- apps/web/src/cloud/linkEnvironment.test.ts | 1 + apps/web/src/connection/runtime.ts | 5 +- .../client-runtime/src/connection/layer.ts | 53 +- .../src/connection/registry.test.ts | 2 + .../src/connection/supervisor.test.ts | 1 + .../src/operations/commands.test.ts | 1 + .../client-runtime/src/rpc/client.test.ts | 36 + packages/client-runtime/src/rpc/client.ts | 6 +- .../client-runtime/src/rpc/session.test.ts | 698 +++++++++++++++++- packages/client-runtime/src/rpc/session.ts | 219 +++++- .../src/state/pullRequests.test.ts | 1 + .../client-runtime/src/state/server.test.ts | 3 +- packages/client-runtime/src/state/server.ts | 93 +-- .../src/state/serverConfigProjection.ts | 79 ++ .../src/state/shell-sync.test.ts | 1 + .../src/state/sourceControl.test.ts | 1 + .../src/state/threads-pagination.test.ts | 1 + .../src/state/threads-sync.test.ts | 1 + packages/client-runtime/src/state/vcs.test.ts | 1 + .../src/state/vcsAction.test.ts | 1 + 20 files changed, 1053 insertions(+), 151 deletions(-) create mode 100644 packages/client-runtime/src/state/serverConfigProjection.ts diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 38e205beabbb1..7ae5e7ed03a98 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -91,6 +91,7 @@ function registryLayer(options?: { const session: RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index b63d01999036d..06c8bf0ccfede 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -30,7 +30,10 @@ type ConnectionLayerSource = | typeof backgroundActivityObserverLayer | typeof backgroundActivityReporterLayer; -const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( +const providedClientConnectionLayer = Layer.merge( + Connection.layerWithOptions({ environmentThemes: true }), + snapshotLoaderLayer, +).pipe( Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 798ec01e2f0de..7927151e5d42d 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -15,30 +15,29 @@ const resolverLayer = ConnectionResolver.layer.pipe( Layer.provide(RemoteEnvironmentAuthorization.layer), ); -const driverLayer = ConnectionDriver.layer.pipe( - Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layer)), -); - -const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); - -const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); - -const connectionServicesLayer = Layer.mergeAll( - registryLayer, - RelayEnvironmentDiscovery.layer, - onboardingLayer, -); - -const connectionStartupLayer = Layer.effectDiscard( - Effect.gen(function* () { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; - yield* registry.start; - yield* platformSource.registrations.pipe( - Stream.runForEach(registry.reconcilePlatform), - Effect.forkScoped, - ); - }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), -); - -export const layer = connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); +export function layerWithOptions(options: RpcSession.RpcSessionOptions) { + const driverLayer = ConnectionDriver.layer.pipe( + Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layerWithOptions(options))), + ); + const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); + const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); + const connectionServicesLayer = Layer.mergeAll( + registryLayer, + RelayEnvironmentDiscovery.layer, + onboardingLayer, + ); + const connectionStartupLayer = Layer.effectDiscard( + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; + yield* registry.start; + yield* platformSource.registrations.pipe( + Stream.runForEach(registry.reconcilePlatform), + Effect.forkScoped, + ); + }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), + ); + return connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); +} + +export const layer = layerWithOptions({}); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 2d0ce41daf7c4..4dbbfe45fe009 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -356,6 +356,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( Effect.succeed({ client: {} as RpcSession.RpcSession["client"], initialConfig: Effect.die(new Error("Config is not used by registry tests.")), + subscribeServerConfig: () => + Stream.die(new Error("Config is not used by registry tests.")), ready: Effect.void, probe: Effect.void, closed: Deferred.await(closed), diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 5e50c44d9610b..d9f54bb326ca6 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -163,6 +163,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: Effect.succeed({ client: TEST_RPC_CLIENT, initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), + subscribeServerConfig: (input) => TEST_RPC_CLIENT.subscribeServerConfig(input), ready: options?.ready?.(attempt) ?? Effect.void, probe: options?.probe?.(attempt) ?? Effect.void, closed: Deferred.await(closed), diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 0cb1650066c47..36bc6a7b296f8 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -57,6 +57,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct const session: RpcSession.RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 507d137caccb6..4e6baba8bef43 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -1,6 +1,8 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, type RelayClientInstallProgressEvent, + type ServerConfigStreamEvent, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -47,6 +49,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -77,6 +80,39 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect("reuses the session config stream instead of opening a duplicate subscription", () => + Effect.gen(function* () { + const event: ServerConfigStreamEvent = { + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + }; + let duplicateSubscriptions = 0; + const client = { + [WS_METHODS.subscribeServerConfig]: () => { + duplicateSubscriptions += 1; + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set( + activeSession, + Option.some({ + ...session(client), + subscribeServerConfig: () => Stream.succeed(event), + }), + ); + + const received = yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe( + Stream.runHead, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(received).toEqual(Option.some(event)); + expect(duplicateSubscriptions).toBe(0); + }), + ); + it.effect("observes unary requests until they complete", () => Effect.gen(function* () { const observations: string[] = []; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index bfe57a6c0dd54..50cc029eccfff 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -203,7 +203,11 @@ export function subscribeDynamic( Option.match({ onNone: () => Stream.empty, onSome: (session) => { - const method = session.client[tag] as ( + const method = ( + tag === WS_METHODS.subscribeServerConfig + ? session.subscribeServerConfig + : session.client[tag] + ) as ( input: EnvironmentRpcInput, ) => Stream.Stream< EnvironmentRpcStreamValue, diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 0af5850bf6c76..aedd85c5de478 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -1,24 +1,41 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, ServerConfig, type ServerConfig as ServerConfigType, + ServerConfigStreamEvent, + type ServerConfigStreamEvent as ServerConfigStreamEventType, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as TestClock from "effect/testing/TestClock"; import * as Socket from "effect/unstable/socket/Socket"; import { + AVAILABLE_CONNECTION_STATE, + ConnectionBlockedError, ConnectionTransientError, PrimaryConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "./session.ts"; +import { makeEnvironmentServerConfigState } from "../state/server.ts"; +import { applyServerConfigProjection } from "../state/serverConfigProjection.ts"; type SocketEventType = "open" | "message" | "close" | "error"; type SocketEvent = { @@ -139,10 +156,24 @@ const RpcRequest = Schema.TaggedStruct("Request", { tag: Schema.String, }); const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); +const isRpcRequest = Schema.is(RpcRequest); +const isPing = Schema.is(Schema.Struct({ _tag: Schema.Literal("Ping") })); const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encodeServerConfig = Schema.encodeSync(ServerConfig); +const encodeServerConfigStreamEvent = Schema.encodeSync(ServerConfigStreamEvent); +const encodeDefect = Schema.encodeSync(Schema.Defect()); const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG); +const THEME_SERVER_CONFIG: ServerConfigType = { + ...SERVER_CONFIG, + environment: { + ...SERVER_CONFIG.environment, + capabilities: { + ...SERVER_CONFIG.environment.capabilities, + environmentThemes: true, + }, + }, +}; +const ENCODED_THEME_SERVER_CONFIG = encodeServerConfig(THEME_SERVER_CONFIG); const LEGACY_SERVER_CONFIG = { ...ENCODED_SERVER_CONFIG, environment: { @@ -153,23 +184,26 @@ const LEGACY_SERVER_CONFIG = { }, }; -const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { +const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* ( + options: RpcSession.RpcSessionOptions = {}, +) { const sockets: TestWebSocket[] = []; const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { const socket = new TestWebSocket(url); sockets.push(socket); return socket as unknown as globalThis.WebSocket; }); - const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); + const layer = RpcSession.layerWithOptions(options).pipe(Layer.provide(constructorLayer)); const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); return { factory, sockets }; }); const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( sockets: ReadonlyArray, + index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const socket = sockets[0]; + const socket = sockets[index]; if (socket) { return socket; } @@ -183,9 +217,9 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const request = socket.sent[index]; + const request = socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)[index]; if (request) { - return decodeRpcRequest(decodeJson(request)); + return request; } yield* Effect.yieldNow; } @@ -195,21 +229,33 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( socket: TestWebSocket, config: unknown = ENCODED_SERVER_CONFIG, + payload: unknown = {}, ) { const request = yield* awaitRequest(socket); expect(request).toMatchObject({ _tag: "Request", - tag: WS_METHODS.serverGetConfig, - payload: {}, + tag: WS_METHODS.subscribeServerConfig, + payload, }); socket.serverMessage( encodeJson({ - _tag: "Exit", + _tag: "Chunk", requestId: request.id, - exit: { - _tag: "Success", - value: config, - }, + values: [{ version: 1, type: "snapshot", config }], + }), + ); +}); + +const publishConfigEvents = Effect.fn("TestRpcSessionFactory.publishConfigEvents")(function* ( + socket: TestWebSocket, + events: ReadonlyArray, +) { + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: events.map((event) => encodeServerConfigStreamEvent(event)), }), ); }); @@ -229,7 +275,9 @@ describe("RpcSessionFactory", () => { const config = yield* session.initialConfig; expect(config).toEqual(SERVER_CONFIG); - expect(socket.sent).toHaveLength(1); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); const probeFiber = yield* Effect.forkChild(session.probe); const probeRequest = yield* awaitRequest(socket, 1); @@ -250,19 +298,25 @@ describe("RpcSessionFactory", () => { ); yield* Fiber.join(probeFiber); - expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ - WS_METHODS.serverGetConfig, - WS_METHODS.serverProbe, - ]); + expect( + socket.sent + .map((message) => decodeJson(message)) + .filter(isRpcRequest) + .map((request) => request.tag), + ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverProbe]); socket.close(1012, "service restart"); const error = yield* Effect.flip(session.closed); + const configStreamError = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runDrain, Effect.flip); expect(error).toBeInstanceOf(ConnectionTransientError); expect(error).toMatchObject({ reason: "transport", message: "Test environment disconnected.", }); + expect(configStreamError).toMatchObject({ _tag: "RpcClientError" }); yield* Effect.yieldNow; expect(sockets).toHaveLength(1); }), @@ -287,6 +341,602 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("replays current config and broadcasts updates to every subscriber", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const collectTwo = session + .subscribeServerConfig({}) + .pipe(Stream.take(2), Stream.runCollect); + const firstSubscriber = yield* Effect.forkChild(collectTwo); + const secondSubscriber = yield* Effect.forkChild(collectTwo); + yield* Effect.yieldNow; + + const shortcut = { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }; + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [ + { + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [{ command: "terminal.toggle", shortcut }], + issues: [], + }, + }, + ], + }), + ); + + const firstEvents = Array.from(yield* Fiber.join(firstSubscriber)); + const secondEvents = Array.from(yield* Fiber.join(secondSubscriber)); + expect(firstEvents.map((event) => event.type)).toEqual(["snapshot", "keybindingsUpdated"]); + expect(secondEvents).toEqual(firstEvents); + + const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); + expect(replay).toMatchObject({ + _tag: "Some", + value: { + type: "snapshot", + config: { keybindings: [{ command: "terminal.toggle", shortcut }] }, + }, + }); + }), + ), + ); + + it.effect("shares only a config subscription with the same theme opt-in", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const shared = yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.runHead); + expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); + + const fallbackFiber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runHead, Effect.forkChild); + const fallbackRequest = yield* awaitRequest(socket, 1); + expect(fallbackRequest).toMatchObject({ + tag: WS_METHODS.subscribeServerConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: fallbackRequest.id, + values: [ + { + version: 1, + type: "snapshot", + config: ENCODED_THEME_SERVER_CONFIG, + }, + ], + }), + ); + expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ + _tag: "Some", + value: { type: "snapshot" }, + }); + }), + ), + ); + + it.effect("replays theme updates and deletion as authoritative events", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const firstThemes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ]; + const replacementThemes = [ + { + id: "midnight", + name: "Midnight", + appearance: "dark" as const, + canvas: "#000000", + accent: "#ffffff", + }, + ]; + const subscriberStarted = yield* Deferred.make(); + const subscriber = yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( + Stream.mapEffect((event) => + Deferred.succeed(subscriberStarted, undefined).pipe(Effect.as(event)), + ), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(subscriberStarted); + yield* publishConfigEvents(socket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: replacementThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }, + ]); + + const liveEvents = Array.from(yield* Fiber.join(subscriber)); + expect(liveEvents.map((event) => event.type)).toEqual([ + "snapshot", + "environmentThemesUpdated", + "environmentThemesUpdated", + "environmentThemesUpdated", + ]); + expect(liveEvents[2]).toMatchObject({ payload: { themes: replacementThemes } }); + + const replay = Array.from( + yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.take(2), Stream.runCollect), + ); + expect(replay.map((event) => event.type)).toEqual(["snapshot", "environmentThemesUpdated"]); + expect(replay[1]).toMatchObject({ payload: { themes: [] } }); + + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: THEME_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }); + for (const event of replay) { + projection = applyServerConfigProjection(projection, event); + } + expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); + }), + ), + ); + + it.effect("recovers a slow subscriber after it misses theme deletion", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const slowSubscriberStarted = yield* Deferred.make(); + const releaseSlowSubscriber = yield* Deferred.make(); + let firstEvent = true; + const slowSubscriber = yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe( + Stream.mapEffect((event) => { + if (!firstEvent) return Effect.succeed(event); + firstEvent = false; + return Deferred.succeed(slowSubscriberStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSlowSubscriber)), + Effect.as(event), + ); + }), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(slowSubscriberStarted); + + const firstThemes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ]; + const themeEvents: ServerConfigStreamEventType[] = [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { + themes: [{ ...firstThemes[0]!, name: "Nightfall 2" }], + }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }, + ]; + const settingsEvents = Array.from( + { length: 65 }, + (): ServerConfigStreamEventType => ({ + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + }), + ); + const allEvents = [...themeEvents, ...settingsEvents]; + const observedByFastSubscriber = yield* Queue.unbounded(); + yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( + Stream.runForEach((event) => Queue.offer(observedByFastSubscriber, event)), + Effect.forkChild, + ); + expect((yield* Queue.take(observedByFastSubscriber)).type).toBe("snapshot"); + for (const event of allEvents) { + yield* publishConfigEvents(socket, [event]); + expect(yield* Queue.take(observedByFastSubscriber)).toEqual(event); + } + yield* Deferred.succeed(releaseSlowSubscriber, undefined); + + const recovered = Array.from(yield* Fiber.join(slowSubscriber)); + expect(recovered.map((event) => event.type)).toEqual([ + "snapshot", + "snapshot", + "environmentThemesUpdated", + ]); + expect(recovered[2]).toMatchObject({ payload: { themes: [] } }); + + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: THEME_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, themeEvents[0]!); + for (const event of recovered.slice(1)) { + projection = applyServerConfigProjection(projection, event); + } + expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); + }), + ), + ); + + it.effect("closes the session when the config source dies", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const closedFiber = yield* session.closed.pipe(Effect.exit, Effect.forkChild); + socket.serverMessage( + encodeJson({ + _tag: "Defect", + defect: encodeDefect(new Error("config stream died")), + }), + ); + + const closed = yield* Fiber.join(closedFiber); + expect(Exit.isFailure(closed)).toBe(true); + if (Exit.isFailure(closed)) { + expect(Cause.hasDies(closed.cause)).toBe(true); + } + }), + ), + ); + + it.effect.each([{ failure: "defect" as const }, { failure: "typed" as const }])( + "keeps durable config state alive after an owned $failure failure", + ({ failure }) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const firstSession = yield* factory.connect(PREPARED); + const firstReady = yield* Effect.forkChild(firstSession.ready); + const firstSocket = yield* awaitSocket(sockets); + firstSocket.open(); + yield* completeInitialConfig(firstSocket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(firstReady); + + const activeSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const configState = yield* makeEnvironmentServerConfigState(true).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + const awaitConfig = (predicate: (config: ServerConfigType) => boolean) => + SubscriptionRef.changes(configState).pipe( + Stream.filter(Option.isSome), + Stream.map((projection) => projection.value.config), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + + const firstThemes = [ + { + id: "first-theme", + name: "First theme", + appearance: "dark" as const, + canvas: "#111111", + accent: "#ffffff", + }, + ]; + const firstThemeState = yield* awaitConfig( + (config) => config.environmentThemes?.[0]?.id === "first-theme", + ).pipe(Effect.forkChild); + yield* publishConfigEvents(firstSocket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + ]); + expect((yield* Fiber.join(firstThemeState)).environmentThemes).toEqual(firstThemes); + + const firstClosed = yield* firstSession.closed.pipe(Effect.exit, Effect.forkChild); + const firstRequest = yield* awaitRequest(firstSocket); + firstSocket.serverMessage( + failure === "defect" + ? encodeJson({ + _tag: "Defect", + defect: encodeDefect(new Error("config stream died")), + }) + : encodeJson({ + _tag: "Exit", + requestId: firstRequest.id, + exit: { + _tag: "Failure", + cause: [ + { + _tag: "Fail", + error: { + _tag: "EnvironmentAuthorizationError", + message: "config subscription rejected", + requiredScope: "orchestration:read", + }, + }, + ], + }, + }), + ); + const firstClosedExit = yield* Fiber.join(firstClosed); + expect(Exit.isFailure(firstClosedExit)).toBe(true); + if (failure === "typed" && Exit.isFailure(firstClosedExit)) { + expect(Cause.squash(firstClosedExit.cause)).toBeInstanceOf(ConnectionBlockedError); + expect(Cause.squash(firstClosedExit.cause)).toMatchObject({ reason: "permission" }); + } + yield* SubscriptionRef.set(activeSession, Option.none()); + + const recoveredConfig = { + ...THEME_SERVER_CONFIG, + environment: { + ...THEME_SERVER_CONFIG.environment, + label: "Recovered environment", + }, + } satisfies ServerConfigType; + const secondSession = yield* factory.connect(PREPARED); + const secondReady = yield* Effect.forkChild(secondSession.ready); + const secondSocket = yield* awaitSocket(sockets, 1); + secondSocket.open(); + yield* completeInitialConfig(secondSocket, encodeServerConfig(recoveredConfig), { + environmentThemes: true, + }); + yield* Fiber.join(secondReady); + + const recoveredState = yield* awaitConfig( + (config) => config.environment.label === "Recovered environment", + ).pipe(Effect.forkChild); + yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); + expect((yield* Fiber.join(recoveredState)).environmentThemes).toEqual(firstThemes); + + const recoveredThemes = [ + { + id: "recovered-theme", + name: "Recovered theme", + appearance: "dark" as const, + canvas: "#000000", + accent: "#eeeeee", + }, + ]; + const liveRecoveredState = yield* awaitConfig( + (config) => config.environmentThemes?.[0]?.id === "recovered-theme", + ).pipe(Effect.forkChild); + yield* publishConfigEvents(secondSocket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: recoveredThemes }, + }, + ]); + expect((yield* Fiber.join(liveRecoveredState)).environmentThemes).toEqual( + recoveredThemes, + ); + }), + ), + ); + + it.effect.each<{ + readonly event: ServerConfigStreamEventType; + readonly expectedConfig: Partial; + }>([ + { + event: { + version: 1, + type: "providerStatuses", + payload: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + }, + }, + expectedConfig: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + }, + }, + { + event: { + version: 1, + type: "settingsUpdated", + payload: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, + }, + }, + }, + expectedConfig: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, + }, + }, + }, + ])( + "preserves $event.type events and includes them in replay snapshots", + ({ event, expectedConfig }) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const subscriber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkChild); + yield* Effect.yieldNow; + + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [encodeServerConfigStreamEvent(event)], + }), + ); + + const events = Array.from(yield* Fiber.join(subscriber)); + expect(events[1]).toEqual(event); + + const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); + expect(replay).toMatchObject({ + _tag: "Some", + value: { + type: "snapshot", + config: expectedConfig, + }, + }); + }), + ), + ); + it.effect("tolerates two missed pong windows before closing the session", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); @@ -301,7 +951,7 @@ describe("RpcSessionFactory", () => { yield* TestClock.adjust("15 seconds"); expect(closedFiber.pollUnsafe()).toBeUndefined(); - expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([ + expect(socket.sent.map((message) => decodeJson(message)).filter(isPing)).toEqual([ { _tag: "Ping" }, { _tag: "Ping" }, { _tag: "Ping" }, @@ -379,10 +1029,12 @@ describe("RpcSessionFactory", () => { ); yield* Fiber.join(probeFiber); - expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ - WS_METHODS.serverGetConfig, - WS_METHODS.serverGetConfig, - ]); + expect( + socket.sent + .map((message) => decodeJson(message)) + .filter(isRpcRequest) + .map((request) => request.tag), + ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverGetConfig]); }), ), ); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 9625effa406f9..7d975be5c9d30 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -1,11 +1,25 @@ -import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import { + type ServerConfig, + type ServerConfigStreamEvent, + WsSubscribeServerConfigRpc, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import type * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as Socket from "effect/unstable/socket/Socket"; @@ -19,15 +33,27 @@ import { ConnectionBlockedError, ConnectionTransientError as ConnectionTransientErrorClass, } from "../connection/model.ts"; +import { + applyServerConfigProjection, + type ServerConfigProjection, + withoutEnvironmentThemes, +} from "../state/serverConfigProjection.ts"; const SOCKET_OPEN_TIMEOUT = "15 seconds"; export interface RpcSession { readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; + readonly subscribeServerConfig: ( + input: ServerConfigSubscriptionInput, + ) => ServerConfigSubscription; readonly ready: Effect.Effect; readonly probe: Effect.Effect; - readonly closed: Effect.Effect; + readonly closed: Effect.Effect; +} + +export interface RpcSessionOptions { + readonly environmentThemes?: boolean; } export class RpcSessionFactory extends Context.Service< @@ -43,8 +69,47 @@ type InitialConfigError = Effect.Error< ReturnType >; type ProbeError = Effect.Error>; +type ServerConfigSubscriptionError = + | Rpc.ErrorExit + | RpcClientError.RpcClientError; +type ServerConfigSubscription = Stream.Stream< + ServerConfigStreamEvent, + ServerConfigSubscriptionError +>; +type ServerConfigSubscriptionInput = Parameters< + WsRpcProtocolClient[typeof WS_METHODS.subscribeServerConfig] +>[0]; +type EnvironmentThemesUpdatedEvent = Extract< + ServerConfigStreamEvent, + { readonly type: "environmentThemesUpdated" } +>; + +interface ServerConfigReplayState { + readonly projection: ServerConfigProjection; + readonly revision: number; + readonly themesEvent: EnvironmentThemesUpdatedEvent | undefined; +} + +interface BufferedServerConfigEvent { + readonly event: ServerConfigStreamEvent; + readonly replay: ServerConfigReplayState; + readonly revision: number; +} + +function serverConfigReplayEvents( + state: ServerConfigReplayState, +): ReadonlyArray { + const snapshot = { + version: 1 as const, + type: "snapshot" as const, + config: withoutEnvironmentThemes(state.projection.config), + }; + return state.themesEvent === undefined ? [snapshot] : [snapshot, state.themesEvent]; +} -function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionAttemptError { +function mapSessionRpcError( + error: InitialConfigError | ProbeError | ServerConfigSubscriptionError, +): ConnectionAttemptError { switch (error._tag) { case "EnvironmentAuthorizationError": return new ConnectionBlockedError({ @@ -65,8 +130,12 @@ function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionA } } -export const make = Effect.gen(function* () { +export const make = Effect.fn("RpcSessionFactory.make")(function* ( + options: RpcSessionOptions = {}, +) { const webSocketConstructor = yield* Socket.WebSocketConstructor; + const serverConfigInput: ServerConfigSubscriptionInput = + options.environmentThemes === true ? { environmentThemes: true } : {}; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { yield* Effect.annotateCurrentSpan({ @@ -113,18 +182,135 @@ export const make = Effect.gen(function* () { const protocolContext = yield* Layer.build(protocolLayer).pipe( Effect.withSpan("environment.websocket.connect"), ); - const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); - const initialConfig = yield* Effect.cached( - client[WS_METHODS.serverGetConfig]({}).pipe( + const protocolClient = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const initialConfigDeferred = yield* Deferred.make(); + const serverConfigExit = yield* Deferred.make(); + const configSubscriptionClosed = yield* Deferred.make(); + const serverConfigState = yield* Ref.make(Option.none()); + const serverConfigUpdates = yield* PubSub.sliding(64); + const configSubscriptionEndedError = new ConnectionTransientErrorClass({ + reason: "remote-unavailable", + detail: `${connection.label} config subscription ended.`, + }); + const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]( + serverConfigInput, + ).pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + const buffered = yield* Ref.modify(serverConfigState, (current) => { + const projection = applyServerConfigProjection( + Option.map(current, (state) => state.projection), + event, + ); + if (Option.isNone(projection)) { + return [Option.none(), current] as const; + } + const next = { + projection: projection.value, + revision: Option.match(current, { + onNone: () => 1, + onSome: (state) => state.revision + 1, + }), + themesEvent: + event.type === "environmentThemesUpdated" + ? event + : event.type === "snapshot" && + event.config.environment.capabilities.environmentThemes !== true + ? undefined + : Option.getOrUndefined(current)?.themesEvent, + } satisfies ServerConfigReplayState; + return [ + Option.some({ event, replay: next, revision: next.revision }), + Option.some(next), + ] as const; + }); + if (Option.isSome(buffered)) { + yield* PubSub.publish(serverConfigUpdates, buffered.value); + } + if (event.type === "snapshot") { + yield* Deferred.succeed(initialConfigDeferred, event.config); + } + }), + ), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) { + return Effect.all([ + Deferred.succeed(serverConfigExit, undefined), + Deferred.fail(configSubscriptionClosed, configSubscriptionEndedError), + ]).pipe(Effect.asVoid); + } + if (Cause.hasInterruptsOnly(exit.cause)) { + return Effect.void; + } + return Effect.all([ + Deferred.failCause(serverConfigExit, exit.cause), + Deferred.failCause(configSubscriptionClosed, Cause.map(exit.cause, mapSessionRpcError)), + ]).pipe(Effect.asVoid); + }), + ); + yield* serverConfigSource.pipe(Effect.forkScoped); + const initialConfig = Effect.raceFirst( + Deferred.await(initialConfigDeferred), + Deferred.await(serverConfigExit).pipe( Effect.mapError(mapSessionRpcError), - Effect.withSpan("environment.initialSync"), + Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)), ), + ).pipe(Effect.withSpan("environment.initialSync")); + const serverConfigEvents = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(serverConfigUpdates); + yield* Effect.raceFirst( + Deferred.await(initialConfigDeferred).pipe(Effect.asVoid), + Deferred.await(serverConfigExit), + ); + const snapshot = yield* Ref.get(serverConfigState); + if (Option.isNone(snapshot)) { + return Stream.empty; + } + const updates = Stream.fromSubscription(subscription).pipe( + Stream.filter((buffered) => buffered.revision > snapshot.value.revision), + Stream.mapAccum( + () => snapshot.value.revision, + (revision, buffered) => [ + buffered.revision, + buffered.revision === revision + 1 + ? [buffered.event] + : serverConfigReplayEvents(buffered.replay), + ], + ), + ); + const terminal = Stream.fromEffect(Deferred.await(serverConfigExit)).pipe(Stream.drain); + return Stream.concat( + Stream.fromIterable(serverConfigReplayEvents(snapshot.value)), + Stream.merge(updates, terminal, { haltStrategy: "either" }), + ); + }), + ).pipe( + Stream.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Stream.failCause(cause); + } + // The supervisor keeps the original cause. Shared durable consumers + // need a transport-shaped failure so they wait for its replacement. + return Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: `${connection.label} config subscription failed.`, + cause, + }), + }), + ); + }), ); + const subscribeServerConfig = (input: ServerConfigSubscriptionInput) => + Equal.equals(input, serverConfigInput) + ? serverConfigEvents + : protocolClient[WS_METHODS.subscribeServerConfig](input); const probe = initialConfig.pipe( Effect.flatMap((config) => (config.environment.capabilities.connectionProbe === true - ? client[WS_METHODS.serverProbe]({}) - : client[WS_METHODS.serverGetConfig]({}) + ? protocolClient[WS_METHODS.serverProbe]({}) + : protocolClient[WS_METHODS.serverGetConfig]({}) ).pipe(Effect.mapError(mapSessionRpcError)), ), Effect.asVoid, @@ -132,19 +318,26 @@ export const make = Effect.gen(function* () { ); return { - client, + client: protocolClient, initialConfig, + subscribeServerConfig, ready: Deferred.await(connected).pipe( Effect.andThen(initialConfig), Effect.asVoid, Effect.raceFirst(Deferred.await(disconnected)), ), probe, - closed: Deferred.await(disconnected), + closed: Effect.raceFirst( + Deferred.await(disconnected), + Deferred.await(configSubscriptionClosed), + ), } satisfies RpcSession; }); return RpcSessionFactory.of({ connect }); }); -export const layer = Layer.effect(RpcSessionFactory, make); +export const layerWithOptions = (options: RpcSessionOptions) => + Layer.effect(RpcSessionFactory, make(options)); + +export const layer = layerWithOptions({}); diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index e5dde9d8427c8..618d5c39418b0 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -32,6 +32,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 17dc09998fbf9..ea170c22e830d 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -30,7 +30,6 @@ import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { - applyServerConfigProjection, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, @@ -42,6 +41,7 @@ import { serverUpdateStateForServerVersion, validateServerUpdateReadyEvent, } from "./server.ts"; +import { applyServerConfigProjection } from "./serverConfigProjection.ts"; const CONFIG = { availableEditors: [], @@ -73,6 +73,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.succeed(CONFIG), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 547fa4a3af289..176b2631e0c57 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -41,6 +41,14 @@ import { type EnvironmentRpcInput, } from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; +import { + applyServerConfigProjection, + type ServerConfigProjection, + withoutEnvironmentThemes, +} from "./serverConfigProjection.ts"; + +// Exported server state includes this type in its inferred public return type. +export type { ServerConfigProjection } from "./serverConfigProjection.ts"; export type ServerUpdateStage = "downloading" | "installing" | "resuming"; @@ -262,75 +270,6 @@ export function resolveServerUpdateProgressResult( return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion })); } -export interface ServerConfigProjection { - readonly config: ServerConfig; - readonly latestEvent: ServerConfigStreamEvent; - readonly source: "cache" | "live"; -} - -export function applyServerConfigProjection( - current: Option.Option, - event: ServerConfigStreamEvent, -): Option.Option { - switch (event.type) { - case "snapshot": { - // A snapshot never carries published themes -- the theme stream owns - // them -- so taking it wholesale would clear the set on every reconnect - // and repaint anyone wearing one until the follow-up event landed. - // Only from a server that still streams them. Reconnecting to one that - // predates the feature must drop the set rather than leave a palette on - // screen that nothing will ever update again. - const carried = - event.config.environment.capabilities.environmentThemes === true && Option.isSome(current) - ? current.value.config.environmentThemes - : undefined; - return Option.some({ - config: - carried === undefined ? event.config : { ...event.config, environmentThemes: carried }, - latestEvent: event, - source: "live" as const, - }); - } - case "keybindingsUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - keybindings: event.payload.keybindings, - issues: event.payload.issues, - }, - latestEvent: event, - source: "live", - })); - case "providerStatuses": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - providers: event.payload.providers, - }, - latestEvent: event, - source: "live", - })); - case "settingsUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - settings: event.payload.settings, - }, - latestEvent: event, - source: "live", - })); - case "environmentThemesUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, - }, - latestEvent: event, - source: "live", - })); - } -} - export function projectServerConfig( current: Option.Option, event: ServerConfigStreamEvent, @@ -345,22 +284,6 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven config, }); -/** - * Keeps a complete server configuration available during reconnects. Server - * config carries the provider/model catalogue used by task creation, so it is - * useful—and safe—to retain after a transport session ends. - */ -/** - * Published themes live only as long as the machine publishes them, so they - * must not survive in the config cache: a restart or an offline load would - * otherwise hand clients palettes the environment has already dropped. - */ -function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { - if (config.environmentThemes === undefined) return config; - const { environmentThemes: _ephemeral, ...rest } = config; - return rest; -} - export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( function* (environmentThemes?: boolean) { const supervisor = yield* EnvironmentSupervisor; diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts new file mode 100644 index 0000000000000..6f4a812cf7e90 --- /dev/null +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -0,0 +1,79 @@ +import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +export interface ServerConfigProjection { + readonly config: ServerConfig; + readonly latestEvent: ServerConfigStreamEvent; + readonly source: "cache" | "live"; +} + +/** + * Cached config keeps the provider and model catalog available across reconnects. + * Published themes are current machine state, so a cache could restore themes + * that the machine no longer publishes. Replay sends themes as a separate event. + */ +export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { + if (config.environmentThemes === undefined) return config; + const { environmentThemes: _ephemeral, ...rest } = config; + return rest; +} + +export function applyServerConfigProjection( + current: Option.Option, + event: ServerConfigStreamEvent, +): Option.Option { + switch (event.type) { + case "snapshot": { + // Wire snapshots never contain published themes. Keep the previous set + // until a capable server sends its authoritative theme event. A legacy + // server cannot send a later removal, so a downgrade must clear the set. + const carried = + event.config.environment.capabilities.environmentThemes === true && Option.isSome(current) + ? current.value.config.environmentThemes + : undefined; + return Option.some({ + config: + carried === undefined ? event.config : { ...event.config, environmentThemes: carried }, + latestEvent: event, + source: "live" as const, + }); + } + case "keybindingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + keybindings: event.payload.keybindings, + issues: event.payload.issues, + }, + latestEvent: event, + source: "live", + })); + case "providerStatuses": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + providers: event.payload.providers, + }, + latestEvent: event, + source: "live", + })); + case "settingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + settings: event.payload.settings, + }, + latestEvent: event, + source: "live", + })); + case "environmentThemesUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, + }, + latestEvent: event, + source: "live", + })); + } +} diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 40e9bd80dc5b8..1c0d838026fb1 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -51,6 +51,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index 393be8e3227df..33c566bf82b6c 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -50,6 +50,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts index 62cad18f89e0a..2cede4f5b3e21 100644 --- a/packages/client-runtime/src/state/threads-pagination.test.ts +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -156,6 +156,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt initialConfig: Effect.succeed({ threadSnapshotPagination: options?.paginationCapability !== false, } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index c2df434e8e774..d94ed3a3fd743 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -112,6 +112,7 @@ function testSession( ? ({ threadResumeCompletionMarker: true } as never) : ({} as never), ), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 0a6264c620786..d7a4692fc3170 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -86,6 +86,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index b936246dc8230..9059729756069 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -84,6 +84,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, From 9dbdcece5f488c66f6b9ac516b610f45bbbb676a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:44:42 +0200 Subject: [PATCH 020/136] fix(web): align un-settle banner action (#9033) --- .../components/chat/ComposerBannerStack.tsx | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 7f51dc601d087..6f335a82b3788 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -269,8 +269,21 @@ function ComposerBannerStackAlert({ className={item.className} > - {item.icon} - {item.title} + + {item.icon} + + + {item.description ? ( + <> + {item.title} + {item.description} + + ) : ( + item.title + )} + {item.actions || item.onDismiss ? ( {item.actions} @@ -284,19 +297,7 @@ function ComposerBannerStackAlert({ ) : null} - {item.description || item.children ? ( - - {item.description ? ( - - - - {item.description} - - - ) : null} - {item.children} - - ) : null} + {item.children ? {item.children} : null} ); } From b5b6abb11e61ad2c9946b8d1346653c6c43d2261 Mon Sep 17 00:00:00 2001 From: Simone Date: Tue, 1 Sep 2026 18:45:12 +0200 Subject: [PATCH 021/136] fix(web): block type-to-focus behind open dialogs (#8139) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c222bb3bf0a21..28d1be21afd82 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -528,7 +528,11 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="tab"]', ].join(","); const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ - '[data-slot="dialog"]', + '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="sheet-popup"]:is([data-open],[data-ending-style])', + '[data-slot="sidebar"][data-mobile="true"]:is([data-open],[data-ending-style])', '[data-slot="menu-popup"]', '[data-slot="select-popup"]', '[data-slot="popover-popup"]', From 2d156a83b96ebf2e4a9c6017251baad357ae6ab1 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 12:48:56 -0400 Subject: [PATCH 022/136] feat(shortcuts): copy active thread reference (#8994) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../T3KeyboardCommandsModule.kt | 46 +++++++ .../expo-module.config.json | 5 +- .../ios/T3KeyboardCommandsModule.swift | 8 ++ .../HardwareKeyboardCommandProvider.tsx | 130 +++++++++++++++++- .../keyboard/hardwareKeyboardCommands.ts | 1 + .../mobile/src/lib/copyTextWithHaptic.test.ts | 14 ++ apps/mobile/src/lib/copyTextWithHaptic.ts | 24 +++- .../src/native/T3KeyboardCommands.android.tsx | 31 +++++ apps/mobile/src/state/use-thread-selection.ts | 1 + apps/server/src/keybindings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 44 ++++++ apps/web/src/components/CommandPalette.tsx | 92 ++++++++++++- apps/web/src/keybindings.test.ts | 31 +++++ docs/user/keybindings.md | 4 + packages/contracts/src/keybindings.test.ts | 7 + packages/contracts/src/keybindings.ts | 1 + packages/shared/package.json | 4 + packages/shared/src/keybindings.ts | 1 + packages/shared/src/threadReference.test.ts | 41 ++++++ packages/shared/src/threadReference.ts | 30 ++++ 20 files changed, 500 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt create mode 100644 apps/mobile/src/native/T3KeyboardCommands.android.tsx create mode 100644 packages/shared/src/threadReference.test.ts create mode 100644 packages/shared/src/threadReference.ts diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt new file mode 100644 index 0000000000000..68608d9eb3f90 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt @@ -0,0 +1,46 @@ +package expo.modules.t3nativecontrols + +import android.content.Context +import android.view.KeyEvent +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +class T3KeyboardCommandsModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3KeyboardCommands") + + View(T3KeyboardCommandsView::class) { + Prop("enabledCommands") { view: T3KeyboardCommandsView, commands: List -> + view.enabledCommands = commands.toSet() + } + Events("onCommand") + } + } +} + +class T3KeyboardCommandsView( + context: Context, + appContext: AppContext +) : ExpoView(context, appContext) { + private val onCommand by EventDispatcher() + var enabledCommands = emptySet() + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val copiesThreadReference = + event.action == KeyEvent.ACTION_DOWN && + event.repeatCount == 0 && + event.keyCode == KeyEvent.KEYCODE_C && + event.isCtrlPressed && + event.isShiftPressed && + !event.isAltPressed && + enabledCommands.contains("copyThreadReference") + if (copiesThreadReference) { + onCommand(mapOf("command" to "copyThreadReference")) + return true + } + return super.dispatchKeyEvent(event) + } +} diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index d9a77f14e2545..8481d61cb5b6d 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -4,6 +4,9 @@ "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] }, "android": { - "modules": ["expo.modules.t3nativecontrols.T3NativeControlsModule"] + "modules": [ + "expo.modules.t3nativecontrols.T3NativeControlsModule", + "expo.modules.t3nativecontrols.T3KeyboardCommandsModule" + ] } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index ea572cc7a018e..f902579f42874 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -29,6 +29,13 @@ public final class T3KeyboardCommandsView: ExpoView { enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"), enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"), enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"), + enabledCommand( + "copyThreadReference", + input: "c", + modifiers: [.command, .shift], + action: #selector(copyThreadReference), + title: "Copy PR Link or Thread ID" + ), enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), ].compactMap { $0 } } @@ -106,6 +113,7 @@ public final class T3KeyboardCommandsView: ExpoView { @objc private func openFiles() { emit("files") } @objc private func openTerminal() { emit("terminal") } @objc private func openReview() { emit("review") } + @objc private func copyThreadReference() { emit("copyThreadReference") } @objc private func handleToggleSidebar() { emit("toggleSidebar") } private func emit(_ command: String) { diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 96a4a63e90183..909bbcf5a7623 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -1,7 +1,22 @@ import { StackActions, useNavigation } from "@react-navigation/native"; -import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type PropsWithChildren, +} from "react"; +import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; +import { useProject, useThreadShell } from "../../state/entities"; +import { useEnvironmentQuery } from "../../state/query"; +import type { GitActionProgress } from "../../state/use-vcs-action-state"; +import { vcsEnvironment } from "../../state/vcs"; +import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay"; import { dispatchHardwareKeyboardCommand, getHardwareKeyboardCommandRegistrationVersion, @@ -11,11 +26,86 @@ import { type HardwareKeyboardCommand, } from "./hardwareKeyboardCommands"; +const EMPTY_COPY_FEEDBACK: GitActionProgress = { + phase: "idle", + label: null, + description: null, +}; +const COPY_FEEDBACK_DISMISS_MS = 3_000; + export function HardwareKeyboardCommandProvider({ children, pathname, }: PropsWithChildren<{ readonly pathname: string }>) { const navigation = useNavigation(); + const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]); + const activeThread = useThreadShell(activeThreadRef); + const activeProjectRef = useMemo( + () => + activeThread === null + ? null + : { + environmentId: activeThread.environmentId, + projectId: activeThread.projectId, + }, + [activeThread], + ); + const activeProject = useProject(activeProjectRef); + const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null; + const gitStatus = useEnvironmentQuery( + activeThread !== null && + activeThread.linkedPullRequest == null && + activeThread.branch !== null && + activeThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: activeThread.environmentId, + input: { cwd: activeThreadCwd }, + }) + : null, + ).data; + const detectedPullRequestUrl = + activeThread?.branch != null && gitStatus?.refName === activeThread.branch + ? (gitStatus.pr?.url ?? null) + : null; + const copyTarget = useMemo( + () => + activeThreadRef === null + ? null + : resolveThreadReferenceCopyTarget({ + threadId: activeThread?.id ?? activeThreadRef.threadId, + linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null, + detectedPullRequestUrl, + }), + [activeThread, activeThreadRef, detectedPullRequestUrl], + ); + const [copyFeedback, setCopyFeedback] = useState(EMPTY_COPY_FEEDBACK); + const copyRequestIdRef = useRef(0); + const copyFeedbackTimerRef = useRef | null>(null); + const dismissCopyFeedback = useCallback(() => { + if (copyFeedbackTimerRef.current !== null) { + clearTimeout(copyFeedbackTimerRef.current); + copyFeedbackTimerRef.current = null; + } + setCopyFeedback(EMPTY_COPY_FEEDBACK); + }, []); + const showCopyFeedback = useCallback((feedback: GitActionProgress) => { + if (copyFeedbackTimerRef.current !== null) { + clearTimeout(copyFeedbackTimerRef.current); + } + setCopyFeedback(feedback); + copyFeedbackTimerRef.current = setTimeout(() => { + copyFeedbackTimerRef.current = null; + setCopyFeedback(EMPTY_COPY_FEEDBACK); + }, COPY_FEEDBACK_DISMISS_MS); + }, []); + useEffect( + () => () => { + if (copyFeedbackTimerRef.current !== null) { + clearTimeout(copyFeedbackTimerRef.current); + } + }, + [], + ); const registrationVersion = useSyncExternalStore( subscribeToHardwareKeyboardCommandRegistrations, getHardwareKeyboardCommandRegistrationVersion, @@ -25,10 +115,11 @@ export function HardwareKeyboardCommandProvider({ const commands = new Set(getRegisteredHardwareKeyboardCommands()); commands.add("newTask"); if (pathname !== "/" || navigation.canGoBack()) commands.add("back"); - if (parseActiveThreadPath(pathname)) { + if (activeThreadRef !== null) { commands.add("files"); commands.add("terminal"); commands.add("review"); + if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference"); } return [...commands]; }, [pathname, registrationVersion, navigation]); @@ -37,6 +128,30 @@ export function HardwareKeyboardCommandProvider({ (command: HardwareKeyboardCommand) => { if (dispatchHardwareKeyboardCommand(command)) return; + if (command === "copyThreadReference") { + if (copyTarget === null) return; + const requestId = ++copyRequestIdRef.current; + void tryCopyTextWithHaptic(copyTarget.value, { + target: copyTarget.clipboardTarget, + }).then((didCopy) => { + if (requestId !== copyRequestIdRef.current) return; + showCopyFeedback( + didCopy + ? { + phase: "success", + label: copyTarget.successTitle, + description: copyTarget.value, + } + : { + phase: "error", + label: copyTarget.failureTitle, + description: "Try again.", + }, + ); + }); + return; + } + if (command === "newTask") { navigation.navigate("NewTaskSheet", { screen: "NewTask" }); return; @@ -62,12 +177,15 @@ export function HardwareKeyboardCommandProvider({ navigation.navigate("ThreadReview", thread); } }, - [pathname, navigation], + [copyTarget, navigation, pathname, showCopyFeedback], ); return ( - - {children} - + <> + + {children} + + + ); } diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index 300434eb736ab..fa1c953849f91 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -8,6 +8,7 @@ export type HardwareKeyboardCommand = | "files" | "terminal" | "review" + | "copyThreadReference" | "toggleSidebar"; type CommandHandler = () => boolean | void; diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts index 236fb44cd6b0e..a9e8cb049fdba 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.test.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -22,6 +22,7 @@ import { CopyTextClipboardWriteError, CopyTextHapticFeedbackError, copyTextWithHaptic, + tryCopyTextWithHaptic, } from "./copyTextWithHaptic"; describe("copyTextWithHaptic", () => { @@ -54,6 +55,19 @@ describe("copyTextWithHaptic", () => { expect(mocks.impactAsync).not.toHaveBeenCalled(); }); + it("reports whether the clipboard write succeeded", async () => { + mocks.setStringAsync.mockResolvedValueOnce(undefined); + + await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(true); + }); + + it("returns false when the clipboard write fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.setStringAsync.mockRejectedValueOnce(new Error("native clipboard failure")); + + await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(false); + }); + it("reports structured failures without including clipboard contents", async () => { const clipboardCause = new Error("native clipboard failure"); const hapticCause = new Error("native haptic failure"); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts index 1cc8c94eef7ae..3a7da03b5ea39 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -27,19 +27,22 @@ export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass { const target = options.target ?? "text"; const feedback = options.feedback ?? "light-impact"; - void (async () => { + const clipboardWrite = (async () => { try { await Clipboard.setStringAsync(value); + return true; } catch (cause) { console.error( new CopyTextClipboardWriteError({ @@ -47,6 +50,7 @@ export function copyTextWithHaptic( cause, }), ); + return false; } })(); @@ -67,4 +71,10 @@ export function copyTextWithHaptic( ); } })(); + + return await clipboardWrite; +} + +export function copyTextWithHaptic(value: string, options: CopyTextWithHapticOptions = {}): void { + void tryCopyTextWithHaptic(value, options); } diff --git a/apps/mobile/src/native/T3KeyboardCommands.android.tsx b/apps/mobile/src/native/T3KeyboardCommands.android.tsx new file mode 100644 index 0000000000000..ff4e817c2002f --- /dev/null +++ b/apps/mobile/src/native/T3KeyboardCommands.android.tsx @@ -0,0 +1,31 @@ +import { requireNativeView } from "expo"; +import type { PropsWithChildren } from "react"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; + +import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands"; + +interface NativeKeyboardCommandsProps extends ViewProps, PropsWithChildren { + readonly enabledCommands: ReadonlyArray; + readonly onCommand: ( + event: NativeSyntheticEvent<{ readonly command: HardwareKeyboardCommand }>, + ) => void; +} + +const NativeKeyboardCommands = requireNativeView("T3KeyboardCommands"); + +export function T3KeyboardCommands( + props: PropsWithChildren<{ + readonly enabledCommands: ReadonlyArray; + readonly onCommand: (command: HardwareKeyboardCommand) => void; + }>, +) { + return ( + props.onCommand(event.nativeEvent.command)} + enabledCommands={props.enabledCommands} + style={{ flex: 1 }} + > + {props.children} + + ); +} diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 8e340ef33bd44..e0e87d609d5f9 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -54,6 +54,7 @@ function threadDetailToShell( interactionMode: thread.interactionMode, branch: thread.branch, worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest ?? null, latestTurn: thread.latestTurn, createdAt: thread.createdAt, updatedAt: thread.updatedAt, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 391e040f6d598..079e7a14edac6 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c"); assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 28d1be21afd82..c4bd71823dbe7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -50,6 +50,7 @@ import { } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { getTerminalLabel, nextTerminalId, @@ -4593,6 +4594,41 @@ function ChatViewContent(props: ChatViewProps) { linkedPullRequest: linkedThreadPullRequest, linkedPullRequestStatus, }); + const activeThreadReferenceCopyTarget = useMemo( + () => + activeThreadId === null || !isServerThread + ? null + : resolveThreadReferenceCopyTarget({ + threadId: activeThreadId, + linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, + detectedPullRequestUrl: activeThreadPr?.url ?? null, + }), + [activeThreadId, activeThreadPr?.url, isServerThread, linkedThreadPullRequest?.url], + ); + const copyActiveThreadReference = useCallback(() => { + const target = activeThreadReferenceCopyTarget; + if (target === null) return; + void writeTextToClipboard(target.value, target.clipboardTarget).then( + (didCopy) => { + if (!didCopy) return; + toastManager.add({ + type: "success", + title: target.successTitle, + description: target.value, + }); + }, + (error) => { + console.error(error); + toastManager.add( + stackedThreadToast({ + type: "error", + title: target.failureTitle, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + ); + }, [activeThreadReferenceCopyTarget]); // The right panel offers the thread's own change request, so it can only offer it once the // branch has one; until then the picker says so rather than opening an empty panel. const addPullRequestSurface = useCallback(() => { @@ -5257,6 +5293,13 @@ function ChatViewContent(props: ChatViewProps) { }); if (!command) return; + if (command === "thread.copyReference") { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) copyActiveThreadReference(); + return; + } + if (command === "thread.settle") { event.preventDefault(); event.stopPropagation(); @@ -5426,6 +5469,7 @@ function ChatViewContent(props: ChatViewProps) { supportsPinning, supportsSettlement, confirmAndUnpinThread, + copyActiveThreadReference, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 52a36cf2ad569..b2af0f0317373 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,10 @@ "use client"; -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { canCreateProjectInEnvironment, getCloneDestinationBrowsePath, @@ -11,6 +15,7 @@ import { } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, @@ -65,6 +70,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useClientSettings } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; @@ -73,11 +79,13 @@ import { filesystemEnvironment } from "../state/filesystem"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; +import { vcsEnvironment } from "../state/vcs"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { useProject, useProjects, useThreadShells } from "../state/entities"; import { useThreadSearch } from "../state/queries"; +import * as ThreadPr from "./ThreadStatusIndicators"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -592,6 +600,65 @@ function OpenCommandPaletteDialog(props: { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); + const changeRequestSnapshotByKey = useAtomValue(ThreadPr.threadChangeRequestSnapshotsAtom); + const activeThreadProject = useProject( + activeThread === null + ? null + : scopeProjectRef(activeThread.environmentId, activeThread.projectId), + ); + const activeThreadCwd = activeThread?.worktreePath ?? activeThreadProject?.workspaceRoot ?? null; + const activeThreadGitStatus = useEnvironmentQuery( + activeThread != null && + activeThread.linkedPullRequest == null && + activeThread.branch !== null && + activeThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: activeThread.environmentId, + input: { cwd: activeThreadCwd }, + }) + : null, + ).data; + const detectedPullRequestUrl = + activeThread == null || activeThread.linkedPullRequest != null + ? null + : (ThreadPr.resolveDisplayedThreadPr({ + threadBranch: activeThread.branch, + gitStatus: activeThreadGitStatus ?? null, + snapshot: changeRequestSnapshotByKey.get( + scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)), + ), + retainTerminalOnBranchMismatch: activeThread.worktreePath === null, + })?.url ?? null); + const activeThreadReferenceCopyTarget = + activeThread == null + ? null + : resolveThreadReferenceCopyTarget({ + threadId: activeThread.id, + linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null, + detectedPullRequestUrl, + }); + const copyActiveThreadReference = useCallback(async () => { + const target = activeThreadReferenceCopyTarget; + if (target === null) return; + try { + const didCopy = await writeTextToClipboard(target.value, target.clipboardTarget); + if (!didCopy) return; + toastManager.add({ + type: "success", + title: target.successTitle, + description: target.value, + }); + } catch (error) { + console.error(error); + toastManager.add( + stackedThreadToast({ + type: "error", + title: target.failureTitle, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, [activeThreadReferenceCopyTarget]); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -1529,6 +1596,20 @@ function OpenCommandPaletteDialog(props: { }); } + if (activeThreadReferenceCopyTarget !== null) { + actionItems.push({ + kind: "action", + value: "action:copy-thread-reference", + searchTerms: ["copy", "pull request", "pr link", "thread id", "reference"], + title: + activeThreadReferenceCopyTarget.kind === "pull-request" ? "Copy PR link" : "Copy thread ID", + description: activeThreadReferenceCopyTarget.value, + icon: , + shortcutCommand: "thread.copyReference", + run: copyActiveThreadReference, + }); + } + actionItems.push({ kind: "action", value: "action:open-file-picker", @@ -2203,6 +2284,13 @@ function OpenCommandPaletteDialog(props: { return; } } + if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + void copyActiveThreadReference(); + return; + } if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { event.preventDefault(); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index b6b98d7389b38..0f3fa825e44b7 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -142,6 +142,11 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, + { + shortcut: modShortcut("c", { shiftKey: true }), + command: "thread.copyReference", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("s", { shiftKey: true }), command: "thread.settle", @@ -213,6 +218,32 @@ describe("settle thread shortcut", () => { }); }); +describe("copy thread reference shortcut", () => { + it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => { + assert.equal( + resolveShortcutCommand(event({ key: "c", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + "thread.copyReference", + ); + assert.equal( + resolveShortcutCommand(event({ key: "c", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + }), + "thread.copyReference", + ); + }); + + it("leaves terminal copy untouched", () => { + assert.isNull( + resolveShortcutCommand(event({ key: "c", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + }); +}); + describe("split/new/close terminal shortcuts", () => { it("requires terminalFocus for default split/new/close bindings", () => { assert.isFalse( diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 318d48cbe2b41..4668ec6409278 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -52,6 +52,10 @@ successful pick; its hover glow and badge preview the element and color family t `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, so add one in **Settings** → **Keybindings** if you want to use it. +`thread.copyReference` copies the active thread's pull request link, or its thread ID when no pull +request is available. Its default shortcut is `mod+shift+c`, and it does not replace terminal copy +while the terminal has focus. + `thread.settle` settles the active thread or restores it when it is already settled. Its default shortcut is `mod+shift+s`, and it does not run while the terminal has focus. diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 9bc9af9d97fd8..411cff23c82ea 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -114,6 +114,13 @@ it.effect("parses keybinding rules", () => when: "!terminalFocus", }); assert.strictEqual(parsedThreadSettle.command, "thread.settle"); + + const parsedThreadCopyReference = yield* decode(KeybindingRule, { + key: "mod+shift+c", + command: "thread.copyReference", + when: "!terminalFocus", + }); + assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference"); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index eb132d5d28449..fe3549d079a70 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand = export const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", + "thread.copyReference", "thread.settle", "thread.pin", ...THREAD_JUMP_KEYBINDING_COMMANDS, diff --git a/packages/shared/package.json b/packages/shared/package.json index aeba7e602a860..25860dbb90966 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -167,6 +167,10 @@ "types": "./src/keybindings.ts", "import": "./src/keybindings.ts" }, + "./threadReference": { + "types": "./src/threadReference.ts", + "import": "./src/threadReference.ts" + }, "./composerTrigger": { "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 56abec02dbd8d..939e88b7f0b68 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, + { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ diff --git a/packages/shared/src/threadReference.test.ts b/packages/shared/src/threadReference.test.ts new file mode 100644 index 0000000000000..693fabc66b160 --- /dev/null +++ b/packages/shared/src/threadReference.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadReferenceCopyTarget } from "./threadReference.ts"; + +describe("resolveThreadReferenceCopyTarget", () => { + it("prefers a durable linked pull request", () => { + expect( + resolveThreadReferenceCopyTarget({ + threadId: "thread-1", + linkedPullRequestUrl: "https://github.com/t3/pr/12", + detectedPullRequestUrl: "https://github.com/t3/pr/13", + }), + ).toMatchObject({ + kind: "pull-request", + value: "https://github.com/t3/pr/12", + successTitle: "PR link copied", + }); + }); + + it("uses a pull request detected from the active branch", () => { + expect( + resolveThreadReferenceCopyTarget({ + threadId: "thread-1", + detectedPullRequestUrl: "https://github.com/t3/pr/13", + }), + ).toMatchObject({ + kind: "pull-request", + value: "https://github.com/t3/pr/13", + }); + }); + + it("falls back to the thread ID", () => { + expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({ + kind: "thread", + value: "thread-1", + clipboardTarget: "thread ID", + successTitle: "Thread ID copied", + failureTitle: "Failed to copy thread ID", + }); + }); +}); diff --git a/packages/shared/src/threadReference.ts b/packages/shared/src/threadReference.ts new file mode 100644 index 0000000000000..cb4fc9dd5c206 --- /dev/null +++ b/packages/shared/src/threadReference.ts @@ -0,0 +1,30 @@ +export interface ThreadReferenceCopyTarget { + readonly kind: "pull-request" | "thread"; + readonly value: string; + readonly clipboardTarget: string; + readonly successTitle: string; + readonly failureTitle: string; +} + +export function resolveThreadReferenceCopyTarget(input: { + readonly threadId: string; + readonly linkedPullRequestUrl?: string | null; + readonly detectedPullRequestUrl?: string | null; +}): ThreadReferenceCopyTarget { + const pullRequestUrl = input.linkedPullRequestUrl ?? input.detectedPullRequestUrl; + return pullRequestUrl + ? { + kind: "pull-request", + value: pullRequestUrl, + clipboardTarget: "pull request link", + successTitle: "PR link copied", + failureTitle: "Failed to copy PR link", + } + : { + kind: "thread", + value: input.threadId, + clipboardTarget: "thread ID", + successTitle: "Thread ID copied", + failureTitle: "Failed to copy thread ID", + }; +} From 261380f91f764673c7fcefcb6a3f775526dbb776 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 1 Sep 2026 12:18:21 -0700 Subject: [PATCH 023/136] fix(mobile): keep thread scroll bounds current after animations (#9013) Co-authored-by: Julius Marminge --- .../features/threads/ThreadDetailScreen.tsx | 2 +- .../src/features/threads/ThreadFeed.tsx | 80 +++--- .../threads/thread-feed-live-follow.test.ts | 11 + .../threads/thread-feed-live-follow.ts | 4 +- .../src/features/threads/thread-work-log.tsx | 64 +++-- patches/@legendapp__list@3.3.5.patch | 246 +++++++++++------- pnpm-lock.yaml | 10 +- 7 files changed, 253 insertions(+), 164 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 41489bca952c4..2736549b2b752 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -688,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onTouchCancel={handleFeedTouchCancel} > = new Set(); +// Let neighboring rows move out of the new rows' space before showing their text. +const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( + THREAD_DISCLOSURE_TRANSITION_MS, +).duration(140); // Entering animations must only play for rows born just now — LegendList // remounts rows when they scroll back into view, and replaying an entrance for @@ -1357,16 +1358,16 @@ function renderFeedEntry( accessibilityState={{ expanded: entry.expanded }} onPress={() => props.onToggleTurnFold(entry.turnId)} hitSlop={4} - className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" + className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" > {entry.label} - ); @@ -1490,7 +1491,7 @@ function renderFeedEntry( const enterAnimated = isFreshTimestamp(message.createdAt); return ( {renderedText.trim().length > 0 ? ( @@ -1832,7 +1833,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); - const previousPresentedFeedRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); const userScrollSettleTimerRef = useRef | null>(null); @@ -1845,12 +1845,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed // whenever the viewport drifts back inside its geometric threshold, which // yanked users off history they were reading every time a stream chunk grew - // a row. Follow breaks when the user scrolls up and away, and re-arms only - // when the list actually returns to the end (or on send / thread switch). + // a row. Scrolling away or expanding a disclosure above the end breaks + // follow; reaching the end (or sending / switching threads) re-arms it. const [endFollowEnabled, setEndFollowEnabled] = useState(true); const endFollowEnabledRef = useRef(true); // A "user scroll session" spans from drag start through the end of its - // momentum; only motion inside a session can break follow, so MVCP + // momentum; scroll events only break follow inside that session, so MVCP // compensations and programmatic scrolls never strand a follower. const userScrollSessionRef = useRef(false); const setEndFollow = useCallback( @@ -2187,33 +2187,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, ], ); - const disclosureEnteringEntryIds = useMemo(() => { - const anchorKey = disclosureAnchorKeyRef.current; - const previousPresentedFeed = previousPresentedFeedRef.current; - if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) { - return EMPTY_DISCLOSURE_ENTRY_IDS; - } - - const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id)); - const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey); - const enteringIds = new Set(); - if (anchorIndex < 0) { - return enteringIds; - } - for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) { - const entryId = presentedFeed[index]!.id; - if (previousIds.has(entryId)) { - break; - } - enteringIds.add(entryId); - } - return enteringIds; - }, [disclosureToggleSettling, presentedFeed]); - - useLayoutEffect(() => { - previousPresentedFeedRef.current = presentedFeed; - }, [presentedFeed]); - // The empty↔filled key below remounts the list and resets its imperative // content-inset override. Seed the fresh instance synchronously with the // current overlay height before the scroll integration's next reaction; @@ -2300,13 +2273,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } disclosureSettleFrameRef.current = requestAnimationFrame(() => { disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { + // A disclosure can leave the reader above the end without a drag. + // Reconcile follow before a later layout or resume can re-pin it. + const listState = props.listRef.current?.getState(); + if (listState) { + transitionEndFollow({ + type: "disclosure-settled", + isAtEnd: listState.isAtEnd, + userScrollSessionActive: userScrollSessionRef.current, + }); + } disclosureAnchorKeyRef.current = null; setDisclosureToggleSettling(false); disclosureSettleFrameRef.current = null; disclosureSettleSecondFrameRef.current = null; }); }); - }, []); + }, [props.listRef, transitionEndFollow]); const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { disclosureAnchorKeyRef.current = anchorKey; @@ -2447,16 +2430,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [expandedWorkRows], ); + // Disclosures can mount existing offscreen rows as well as new work rows. + // Fade those in after movement; never retain removed rows over replacements. const renderItem = useCallback( (info: { item: ThreadFeedEntry; index: number }) => ( {renderFeedEntry(info, { environmentId: props.environmentId, @@ -2486,7 +2466,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ), [ copiedRowId, - disclosureEnteringEntryIds, + disclosureToggleSettling, expandedWorkRows, terminalAssistantMessageIds, unsettledTurnId, diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 2ea2079234296..b776008058768 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => { ).toBe(false); }); + it.each([ + { isAtEnd: false, userScrollSessionActive: false, expected: false }, + { isAtEnd: true, userScrollSessionActive: false, expected: true }, + { isAtEnd: false, userScrollSessionActive: true, expected: false }, + { isAtEnd: true, userScrollSessionActive: true, expected: false }, + ])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => { + expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe( + expected, + ); + }); + it("re-arms at the actual end only after the user scroll session ends", () => { expect( resolveThreadFeedLiveFollow(false, { diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index 312fd67473e59..83d5cc22faed2 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent = readonly userScrollSessionActive: boolean; } | { - readonly type: "scroll"; + readonly type: "scroll" | "disclosure-settled"; readonly isAtEnd: boolean; readonly userScrollSessionActive: boolean; }; @@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow( return false; case "user-scroll-end": return event.userScrollSessionActive ? event.isAtEnd : current; + case "disclosure-settled": + return !event.userScrollSessionActive && event.isAtEnd; case "scroll": if (event.userScrollSessionActive) { return false; diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index b57b808d7c020..d26f8976b9ca0 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; import { MaskedView } from "@expo/ui/community/masked-view"; import { useIsFocused } from "@react-navigation/native"; -import { useEffect, useId, useState, type ComponentProps } from "react"; +import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react"; import { AccessibilityInfo, AppState, @@ -46,6 +46,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); +export function ThreadDisclosureChevron(props: { + readonly expanded: boolean; + readonly collapsedDirection: "right" | "down"; + readonly size: number; + readonly tintColor: ColorValue; +}) { + const expandedAngle = props.collapsedDirection === "right" ? 90 : 180; + const rotation = useSharedValue(props.expanded ? expandedAngle : 0); + + useLayoutEffect(() => { + rotation.value = withTiming(props.expanded ? expandedAngle : 0, { + duration: THREAD_DISCLOSURE_TRANSITION_MS, + reduceMotion: ReduceMotion.System, + }); + }, [expandedAngle, props.expanded, rotation]); + + const rotationStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + return ( + + + + ); +} + function ShimmerWorkContent(props: { readonly highlighted: boolean; readonly icon: AppSymbolName; @@ -264,7 +302,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8 const WORK_ROW_GAP = 1; // gap-px const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 -export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4) +export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8 export function collapsedWorkLogHeight(activities: ReadonlyArray): number { const rows = activities; @@ -376,15 +414,11 @@ export function ThreadWorkLog(props: { ) : null} {canExpand ? ( - ) : null} @@ -444,7 +478,7 @@ export function ThreadWorkGroupToggle(props: { const icon = toolGroupSummarySymbolName(props.summaryKind); return ( - + )} - diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index c3b5ee5b0e3c3..2fea500e1bdef 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -241,10 +241,35 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c3ffe83af 100644 +index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df752a843de0 100644 --- a/react-native.js +++ b/react-native.js -@@ -954,7 +954,7 @@ function setInitialRenderState(ctx, { +@@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { + return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; + } + ++// Size-only changes may not emit a scroll event to refresh the edge signal. ++function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { ++ const { queuedInitialLayout, scroll, scrollLength } = ctx.state; ++ if (!(contentSize > 0 && queuedInitialLayout)) { ++ return peek$(ctx, "isAtEnd"); ++ } ++ return contentSize < scrollLength || contentSize - scroll - scrollLength - getContentInsetEnd(ctx) <= EDGE_POSITION_EPSILON; ++} ++ + // src/utils/checkAtBottom.ts + function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { + var _a3; +@@ -737,7 +746,7 @@ function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { + const insetEnd = getContentInsetEnd(ctx); + const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; + const isContentLess = contentSize < scrollLength; +- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isAtEnd", getIsAtEnd(ctx, contentSize)); + set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); + set$( + ctx, +@@ -954,7 +963,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -253,7 +278,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1090,7 +1090,7 @@ function getRawContentLength(ctx) { +@@ -1090,7 +1099,7 @@ function getRawContentLength(ctx) { function getAlignItemsAtEndPadding(ctx) { const { state } = ctx; const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; @@ -262,7 +287,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c } function updateContentMetricsState(ctx) { const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; -@@ -1115,6 +1115,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { +@@ -1115,6 +1124,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { totalSize += add; } if (prevTotalSize !== totalSize) { @@ -273,7 +298,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { state.pendingTotalSize = totalSize; } else { -@@ -1304,18 +1308,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1304,18 +1317,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -299,7 +324,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c return clampedOffset; } -@@ -1451,10 +1460,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1451,10 +1469,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -312,7 +337,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1503,7 +1512,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1503,7 +1521,10 @@ function checkFinishedScrollFallback(ctx) { ); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -324,7 +349,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1560,15 +1572,28 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1560,15 +1581,28 @@ function doMaintainScrollAtEnd(ctx) { } = state; const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); @@ -354,7 +379,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1591,9 +1616,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1591,9 +1625,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -376,7 +401,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c } setTimeout( () => { -@@ -1624,6 +1658,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1624,6 +1667,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -387,7 +412,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1728,7 +1766,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1728,7 +1775,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -398,7 +423,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1790,7 +1830,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1790,7 +1839,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -407,7 +432,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1923,7 +1963,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1923,7 +1972,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -416,7 +441,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2320,8 +2360,121 @@ function scrollToIndex(ctx, { +@@ -2320,8 +2369,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -538,7 +563,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2747,7 +2900,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2747,7 +2909,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -549,7 +574,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4672,7 +4827,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4672,7 +4836,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; @@ -559,7 +584,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c } else if (anchorIndex >= 0) { isReady = false; } -@@ -4692,6 +4848,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4692,6 +4857,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -572,7 +597,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c } return nextSize; } -@@ -5715,6 +5877,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5715,6 +5886,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ horizontal }) { const ctx = useStateContext(); @@ -580,7 +605,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c const columnWrapperStyle = ctx.columnWrapperStyle; const animSize = useValue$("totalSize"); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); -@@ -5725,6 +5888,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5725,6 +5897,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ opacity: isVisible ? 1 : 0, width: animSize } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; @@ -594,7 +619,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c if (columnWrapperStyle) { const { columnGap, rowGap, gap } = columnWrapperStyle; const gapX = columnGap || gap || 0; -@@ -5745,7 +5915,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5745,7 +5924,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ } } } @@ -604,7 +629,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c }); var Containers = typedMemo(function Containers2({ freshDataTransitionEpoch, -@@ -5896,7 +6067,12 @@ var StyleSheet = ReactNative.StyleSheet; +@@ -5896,7 +6076,12 @@ var StyleSheet = ReactNative.StyleSheet; // src/components/ListComponent.tsx var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { @@ -617,7 +642,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c if (alignItemsAtEndPadding <= 0) { return null; } -@@ -5929,8 +6105,12 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5929,8 +6114,12 @@ var ListComponent = typedMemo(function ListComponent2({ refScrollView, renderScrollComponent, onLayoutFooter, @@ -630,7 +655,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c scrollAdjustHandler, snapToIndices, stickyHeaderConfig, -@@ -6001,7 +6181,17 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6001,7 +6190,17 @@ var ListComponent = typedMemo(function ListComponent2({ SnapOrScroll, { ...rest, @@ -649,7 +674,16 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -7075,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6751,7 +6950,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { + endBuffered: state.endBuffered, + getAverageItemSizes: () => getAverageItemSizes(state), + indexByKey: (key) => state.indexByKey.get(key), +- isAtEnd: peek$(ctx, "isAtEnd"), ++ isAtEnd: getIsAtEnd(ctx), + isAtStart: peek$(ctx, "isAtStart"), + isEndReached: state.isEndReached, + isNearEnd: peek$(ctx, "isNearEnd"), +@@ -7075,6 +7274,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -657,7 +691,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7132,10 +7323,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7132,10 +7332,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const animatedPropsInternal = props.animatedPropsInternal; const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; const positionComponentInternal = props.positionComponentInternal; @@ -670,7 +704,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7200,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7200,7 +7402,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -679,7 +713,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = React2.useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7341,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7341,6 +7543,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -687,7 +721,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c data: dataProp, dataKey, dataVersion, -@@ -7372,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7372,6 +7575,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -695,7 +729,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), stickyPositionComponentInternal, -@@ -7423,6 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7423,6 +7627,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -709,7 +743,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -7547,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7547,6 +7758,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -717,7 +751,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7643,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7643,6 +7855,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded () => ({ getRenderedItem: (key) => getRenderedItem(ctx, key), onMomentumScrollEnd: (event) => { @@ -725,7 +759,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c checkFinishedScrollFallback(ctx); if (state.props.onMomentumScrollEnd) { state.props.onMomentumScrollEnd(event); -@@ -7651,6 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7651,6 +7864,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -734,7 +768,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7676,11 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7676,11 +7891,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, @@ -754,10 +788,35 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318d57e29dc 100644 +index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff7e3ad022 100644 --- a/react-native.mjs +++ b/react-native.mjs -@@ -933,7 +933,7 @@ function setInitialRenderState(ctx, { +@@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { + return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; + } + ++// Size-only changes may not emit a scroll event to refresh the edge signal. ++function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { ++ const { queuedInitialLayout, scroll, scrollLength } = ctx.state; ++ if (!(contentSize > 0 && queuedInitialLayout)) { ++ return peek$(ctx, "isAtEnd"); ++ } ++ return contentSize < scrollLength || contentSize - scroll - scrollLength - getContentInsetEnd(ctx) <= EDGE_POSITION_EPSILON; ++} ++ + // src/utils/checkAtBottom.ts + function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { + var _a3; +@@ -716,7 +725,7 @@ function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { + const insetEnd = getContentInsetEnd(ctx); + const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; + const isContentLess = contentSize < scrollLength; +- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isAtEnd", getIsAtEnd(ctx, contentSize)); + set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); + set$( + ctx, +@@ -933,7 +942,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -766,7 +825,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1069,7 +1069,7 @@ function getRawContentLength(ctx) { +@@ -1069,7 +1078,7 @@ function getRawContentLength(ctx) { function getAlignItemsAtEndPadding(ctx) { const { state } = ctx; const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; @@ -775,7 +834,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 } function updateContentMetricsState(ctx) { const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; -@@ -1094,6 +1094,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { +@@ -1094,6 +1103,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { totalSize += add; } if (prevTotalSize !== totalSize) { @@ -786,7 +845,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { state.pendingTotalSize = totalSize; } else { -@@ -1283,18 +1287,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1283,18 +1296,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -812,7 +871,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 return clampedOffset; } -@@ -1430,10 +1439,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1430,10 +1448,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -825,7 +884,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1482,7 +1491,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1482,7 +1500,10 @@ function checkFinishedScrollFallback(ctx) { ); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -837,7 +896,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1539,15 +1551,28 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1539,15 +1560,28 @@ function doMaintainScrollAtEnd(ctx) { } = state; const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); @@ -867,7 +926,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1570,9 +1595,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1570,9 +1604,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -889,7 +948,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 } setTimeout( () => { -@@ -1603,6 +1637,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1603,6 +1646,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -900,7 +959,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1707,7 +1745,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1707,7 +1754,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -911,7 +970,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1769,7 +1809,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1769,7 +1818,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -920,7 +979,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1902,7 +1942,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1902,7 +1951,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -929,7 +988,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2299,8 +2339,121 @@ function scrollToIndex(ctx, { +@@ -2299,8 +2348,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -1051,7 +1110,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2726,7 +2879,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2726,7 +2888,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -1062,7 +1121,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4651,7 +4806,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4651,7 +4815,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; @@ -1072,7 +1131,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4671,6 +4827,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4671,6 +4836,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -1085,7 +1144,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 } return nextSize; } -@@ -5694,6 +5856,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5694,6 +5865,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ horizontal }) { const ctx = useStateContext(); @@ -1093,7 +1152,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 const columnWrapperStyle = ctx.columnWrapperStyle; const animSize = useValue$("totalSize"); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); -@@ -5704,6 +5867,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5704,6 +5876,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ opacity: isVisible ? 1 : 0, width: animSize } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; @@ -1107,7 +1166,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 if (columnWrapperStyle) { const { columnGap, rowGap, gap } = columnWrapperStyle; const gapX = columnGap || gap || 0; -@@ -5724,7 +5894,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -5724,7 +5903,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ } } } @@ -1117,7 +1176,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 }); var Containers = typedMemo(function Containers2({ freshDataTransitionEpoch, -@@ -5875,7 +6046,12 @@ var StyleSheet = StyleSheet$1; +@@ -5875,7 +6055,12 @@ var StyleSheet = StyleSheet$1; // src/components/ListComponent.tsx var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { @@ -1130,7 +1189,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 if (alignItemsAtEndPadding <= 0) { return null; } -@@ -5908,8 +6084,12 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5908,8 +6093,12 @@ var ListComponent = typedMemo(function ListComponent2({ refScrollView, renderScrollComponent, onLayoutFooter, @@ -1143,7 +1202,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 scrollAdjustHandler, snapToIndices, stickyHeaderConfig, -@@ -5980,7 +6160,17 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -5980,7 +6169,17 @@ var ListComponent = typedMemo(function ListComponent2({ SnapOrScroll, { ...rest, @@ -1162,7 +1221,16 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 contentContainerStyle: [ horizontal ? { height: "100%" } : {}, contentContainerStyle, -@@ -7054,6 +7244,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6730,7 +6929,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { + endBuffered: state.endBuffered, + getAverageItemSizes: () => getAverageItemSizes(state), + indexByKey: (key) => state.indexByKey.get(key), +- isAtEnd: peek$(ctx, "isAtEnd"), ++ isAtEnd: getIsAtEnd(ctx), + isAtStart: peek$(ctx, "isAtStart"), + isEndReached: state.isEndReached, + isNearEnd: peek$(ctx, "isNearEnd"), +@@ -7054,6 +7253,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -1170,7 +1238,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7111,10 +7302,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7111,10 +7311,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const animatedPropsInternal = props.animatedPropsInternal; const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; const positionComponentInternal = props.positionComponentInternal; @@ -1183,7 +1251,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps } = rest; -@@ -7179,7 +7372,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7179,7 +7381,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -1192,7 +1260,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7320,6 +7513,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7320,6 +7522,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -1200,7 +1268,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 data: dataProp, dataKey, dataVersion, -@@ -7351,6 +7545,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7351,6 +7554,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded renderItem, rtl, snapToIndices, @@ -1208,7 +1276,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), stickyPositionComponentInternal, -@@ -7402,6 +7597,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7402,6 +7606,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -1222,7 +1290,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -7526,6 +7728,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7526,6 +7737,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded [ dataKey, dataVersion, @@ -1230,7 +1298,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 memoizedLastItemKeys.join(","), numColumnsProp, nextScrollAxisGap, -@@ -7622,6 +7825,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7622,6 +7834,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded () => ({ getRenderedItem: (key) => getRenderedItem(ctx, key), onMomentumScrollEnd: (event) => { @@ -1238,7 +1306,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 checkFinishedScrollFallback(ctx); if (state.props.onMomentumScrollEnd) { state.props.onMomentumScrollEnd(event); -@@ -7630,6 +7834,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7630,6 +7843,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -1247,7 +1315,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7655,11 +7861,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7655,11 +7870,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded ListFooterComponent, ListFooterComponentStyle, ListHeaderComponent, @@ -1358,7 +1426,7 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613 * Number of columns to render items in. * @default 1 diff --git a/reanimated.js b/reanimated.js -index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71ff8ce9f4d 100644 +index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 100644 --- a/reanimated.js +++ b/reanimated.js @@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( @@ -1404,7 +1472,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f ref: refView, style: viewStyle, ...rest -@@ -141,6 +162,96 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( +@@ -141,6 +162,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( children ); }); @@ -1418,12 +1486,12 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f + const previousEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0); + const animationRunRef = React__namespace.useRef(0); + const isAnimatingRef = React__namespace.useRef(false); -+ const [isAnimating, setIsAnimating] = React__namespace.useState(false); + const transition = props.layoutTransition; + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; -+ const startsEligibleAnimation = canAnimate && logicalSize !== previousLogicalSizeRef.current && renderEpoch !== previousEpochRef.current && ctx.state.contentSizeAnimationEligible; -+ const useAnimatedAxis = isAnimating || startsEligibleAnimation; ++ // Keep the animated size attached after transitions finish. Reanimated ++ // retains removed animated props, so switching to a static size leaves ++ // native scroll bounds stuck while logical measurements keep changing. + const animatedStyle = Reanimated.useAnimatedStyle(() => { + const size = Math.max(0, baseSize.value + animatedDelta.value); + return horizontal ? { width: size } : { height: size }; @@ -1433,7 +1501,6 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f + return; + } + isAnimatingRef.current = false; -+ setIsAnimating(false); + const state = ctx.state; + if (state.contentSizeAnimationActiveEpoch !== epoch) { + return; @@ -1450,13 +1517,15 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f + const previousLogicalSize = previousLogicalSizeRef.current; + const delta = logicalSize - previousLogicalSize; + const isNewEpoch = epoch !== previousEpochRef.current; -+ const isNewEligibleEpoch = canAnimate && isNewEpoch && state.contentSizeAnimationEligible; ++ // Retarget measurements received mid-animation, even in the same epoch. ++ // Updating only the base keeps the old delta and can shrink scroll bounds. ++ const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible); + previousLogicalSizeRef.current = logicalSize; + previousEpochRef.current = epoch; + if (!delta) { + return; + } -+ if (!isNewEligibleEpoch) { ++ if (!shouldAnimate) { + baseSize.value = logicalSize; + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { + state.contentSizeAnimationEligible = false; @@ -1480,7 +1549,6 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f + state.contentSizeAnimationActiveSignals.add(signalName); + const wasAnimating = isAnimatingRef.current; + isAnimatingRef.current = true; -+ setIsAnimating(true); + const run = ++animationRunRef.current; + Reanimated.runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { + "worklet"; @@ -1495,13 +1563,12 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f + })); + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); -+ const sizeStyle = useAnimatedAxis ? animatedStyle : horizontal ? { width: logicalSize } : { height: logicalSize }; -+ return /* @__PURE__ */ React__namespace.createElement(Reanimated__default.default.View, { ...rest, style: [style, sizeStyle] }, children); ++ return /* @__PURE__ */ React__namespace.createElement(Reanimated__default.default.View, { ...rest, style: [style, animatedStyle] }, children); +}); function setSharedValueValue(sharedValue, value) { if (!sharedValue) { return; -@@ -245,10 +356,19 @@ var LegendListForwardedRef = typedMemo( +@@ -245,10 +355,19 @@ var LegendListForwardedRef = typedMemo( ); }; }, [hasItemLayoutAnimation, recycleItems]); @@ -1522,7 +1589,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f renderScrollComponent: renderReanimatedScrollComponent, ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} diff --git a/reanimated.mjs b/reanimated.mjs -index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a5725398942 100644 +index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 100644 --- a/reanimated.mjs +++ b/reanimated.mjs @@ -1,7 +1,7 @@ @@ -1577,7 +1644,7 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 ref: refView, style: viewStyle, ...rest -@@ -117,6 +138,96 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( +@@ -117,6 +138,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( children ); }); @@ -1591,12 +1658,12 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 + const previousEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0); + const animationRunRef = React.useRef(0); + const isAnimatingRef = React.useRef(false); -+ const [isAnimating, setIsAnimating] = React.useState(false); + const transition = props.layoutTransition; + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; -+ const startsEligibleAnimation = canAnimate && logicalSize !== previousLogicalSizeRef.current && renderEpoch !== previousEpochRef.current && ctx.state.contentSizeAnimationEligible; -+ const useAnimatedAxis = isAnimating || startsEligibleAnimation; ++ // Keep the animated size attached after transitions finish. Reanimated ++ // retains removed animated props, so switching to a static size leaves ++ // native scroll bounds stuck while logical measurements keep changing. + const animatedStyle = useAnimatedStyle(() => { + const size = Math.max(0, baseSize.value + animatedDelta.value); + return horizontal ? { width: size } : { height: size }; @@ -1606,7 +1673,6 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 + return; + } + isAnimatingRef.current = false; -+ setIsAnimating(false); + const state = ctx.state; + if (state.contentSizeAnimationActiveEpoch !== epoch) { + return; @@ -1623,13 +1689,15 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 + const previousLogicalSize = previousLogicalSizeRef.current; + const delta = logicalSize - previousLogicalSize; + const isNewEpoch = epoch !== previousEpochRef.current; -+ const isNewEligibleEpoch = canAnimate && isNewEpoch && state.contentSizeAnimationEligible; ++ // Retarget measurements received mid-animation, even in the same epoch. ++ // Updating only the base keeps the old delta and can shrink scroll bounds. ++ const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible); + previousLogicalSizeRef.current = logicalSize; + previousEpochRef.current = epoch; + if (!delta) { + return; + } -+ if (!isNewEligibleEpoch) { ++ if (!shouldAnimate) { + baseSize.value = logicalSize; + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { + state.contentSizeAnimationEligible = false; @@ -1653,7 +1721,6 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 + state.contentSizeAnimationActiveSignals.add(signalName); + const wasAnimating = isAnimatingRef.current; + isAnimatingRef.current = true; -+ setIsAnimating(true); + const run = ++animationRunRef.current; + runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { + "worklet"; @@ -1668,13 +1735,12 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 + })); + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); -+ const sizeStyle = useAnimatedAxis ? animatedStyle : horizontal ? { width: logicalSize } : { height: logicalSize }; -+ return /* @__PURE__ */ React.createElement(Reanimated.View, { ...rest, style: [style, sizeStyle] }, children); ++ return /* @__PURE__ */ React.createElement(Reanimated.View, { ...rest, style: [style, animatedStyle] }, children); +}); function setSharedValueValue(sharedValue, value) { if (!sharedValue) { return; -@@ -221,10 +332,19 @@ var LegendListForwardedRef = typedMemo( +@@ -221,10 +331,19 @@ var LegendListForwardedRef = typedMemo( ); }; }, [hasItemLayoutAnimation, recycleItems]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a1a2067a8316..262203a10002c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,7 +86,7 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 - '@legendapp/list@3.3.5': f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b + '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -228,7 +228,7 @@ importers: version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -572,7 +572,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -12892,7 +12892,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12900,7 +12900,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.5(patch_hash=f786e0ada19a32703f71a7230a819697ebff2527873dfb9997c8455947e0060b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) From 643b21edaa08eb1d19d633dbb0b275164bfa60be Mon Sep 17 00:00:00 2001 From: Will Sheldon Date: Tue, 1 Sep 2026 12:19:20 -0700 Subject: [PATCH 024/136] fix(server): cache project favicon resolution (#9080) Co-authored-by: Claude Opus 5 (1M context) --- .../project/ProjectFaviconResolver.test.ts | 60 ++++++++++++++ .../src/project/ProjectFaviconResolver.ts | 80 ++++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index c610781ea9be3..2c7b0f7bdc62c 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -1,10 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { TestClock } from "effect/testing"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -49,6 +51,64 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { + it.effect("serves repeated resolves from cache instead of re-walking candidates", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "public/favicon.svg", "public"); + + const resolved = yield* resolver.resolvePath(cwd); + expect(resolved?.endsWith("public/favicon.svg")).toBe(true); + + // `favicon.svg` outranks `public/favicon.svg`, so a resolver that walked + // the candidate list again would switch to it. Staying on the original + // answer is only possible from cache. + yield* writeTextFile(cwd, "favicon.svg", "root"); + + for (const _attempt of [1, 2, 3]) { + expect(yield* resolver.resolvePath(cwd)).toBe(resolved); + } + + yield* TestClock.adjust(Duration.minutes(11)); + + expect((yield* resolver.resolvePath(cwd))?.endsWith("/favicon.svg")).toBe(true); + expect(yield* resolver.resolvePath(cwd)).not.toBe(resolved); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("falls back at once when a cached favicon is deleted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + expect(yield* resolver.resolvePath(cwd)).not.toBeNull(); + + yield* fileSystem.remove(path.join(cwd, "favicon.svg")).pipe(Effect.orDie); + + // Still inside the positive TTL: the cached path must not be served. + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("re-probes for a favicon added after a miss once the negative TTL expires", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + + yield* TestClock.adjust(Duration.minutes(2)); + + expect(yield* resolver.resolvePath(cwd)).not.toBeNull(); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("prefers well-known favicon files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 9d9a5bddc791a..2b68f5310d903 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -6,8 +6,11 @@ * * @module ProjectFaviconResolver */ +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 FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -18,6 +21,30 @@ import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; +// Resolution walks up to 12 well-known paths plus 7 source files, so a miss +// costs ~20 filesystem probes. AssetAccess resolves on every project-favicon +// asset URL, and a project's icon does not move, so the answer is cached. +const FAVICON_CACHE_CAPACITY = 512; +const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10); +const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1); + +function faviconCacheKey(cwd: string, faviconPath?: string): string { + return `${faviconPath ?? ""}\0${cwd}`; +} + +function parseFaviconCacheKey(key: string): { + readonly cwd: string; + readonly faviconPath?: string; +} { + const separatorIndex = key.indexOf("\0"); + if (separatorIndex === -1) { + return { cwd: key }; + } + const faviconPath = key.slice(0, separatorIndex); + const cwd = key.slice(separatorIndex + 1); + return faviconPath.length === 0 ? { cwd } : { cwd, faviconPath }; +} + // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ "favicon.svg", @@ -178,9 +205,10 @@ export const make = Effect.gen(function* () { return null; }); - const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( - "ProjectFaviconResolver.resolvePath", - )(function* (cwd, faviconPath) { + const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( + cwd: string, + faviconPath?: string, + ): Effect.fn.Return { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => @@ -267,6 +295,52 @@ export const make = Effect.gen(function* () { return null; }); + const faviconCache = yield* Cache.makeWith( + (key) => { + const { cwd, faviconPath } = parseFaviconCacheKey(key); + return resolvePathUncached(cwd, faviconPath); + }, + { + capacity: FAVICON_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (value: string | null) => + value === null ? FAVICON_NEGATIVE_CACHE_TTL : FAVICON_POSITIVE_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + + const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( + "ProjectFaviconResolver.resolvePath", + )(function* (cwd, faviconPath) { + const key = faviconCacheKey(cwd, faviconPath); + const cached = yield* Cache.get(faviconCache, key); + if (cached === null) { + return null; + } + + // A hit still confirms the file with one stat rather than the ~20 probes a + // full walk costs, so a deleted icon falls back at once instead of after + // the TTL. + const stats = yield* optionOnNotFound(fileSystem.stat(cached)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: cwd, + absolutePath: cached, + cause, + }), + ), + ); + if (Option.isSome(stats) && stats.value.type === "File") { + return cached; + } + + yield* Cache.invalidate(faviconCache, key); + return yield* Cache.get(faviconCache, key); + }); + return ProjectFaviconResolver.of({ resolvePath }); }); From c17d02cff98f2e7b590d4c3a5775d1faa47c2e7b Mon Sep 17 00:00:00 2001 From: mic <85814106+q1@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:20:56 -0700 Subject: [PATCH 025/136] feat(claude): add Claude Fable 5.1 model (#9078) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 23 +++++++ .../src/provider/Layers/ClaudeProvider.ts | 67 ++++++++++++++++--- .../provider/Layers/ProviderRegistry.test.ts | 56 ++++++++++++++++ .../server/src/provider/ModelManifest.test.ts | 14 ++-- apps/server/src/provider/model-manifest.json | 2 +- packages/contracts/src/model.ts | 3 + packages/shared/src/model.test.ts | 1 + 7 files changed, 152 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2f0efeac5f534..9e56176a032f7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -525,6 +525,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves xhigh effort for Claude Fable 5.1", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-fable-5-1", + [{ id: "effort", value: "xhigh" }], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("preserves xhigh effort for Claude Fable 5", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f37cf0534feaf..b3665977ef3fa 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -51,12 +51,48 @@ const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, } as const; +const MINIMUM_CLAUDE_FABLE_5_1_VERSION = "2.1.257"; const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ + { + slug: "claude-fable-5-1", + name: "Claude Fable 5.1", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { + value: "ultracode", + label: "Ultracode", + description: "xhigh effort plus multi-agent workflow orchestration", + }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], + }), + ], + }), + }, { slug: "claude-fable-5", name: "Claude Fable 5", @@ -325,6 +361,10 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ // so the catalog itself carries no `isLegacy` flags. const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG; +function supportsClaudeFable51(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_1_VERSION) >= 0 : false; +} + function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; } @@ -345,6 +385,9 @@ function getBuiltInClaudeModelsForVersion( version: string | null | undefined, ): ReadonlyArray { return BUILT_IN_MODELS.filter((model) => { + if (model.slug === "claude-fable-5-1") { + return supportsClaudeFable51(version); + } if (model.slug === "claude-opus-5") { return supportsClaudeOpus5(version); } @@ -361,6 +404,11 @@ function getBuiltInClaudeModelsForVersion( }); } +function formatClaudeFable51UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Fable 5.1. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_1_VERSION} or newer to access it.`; +} + function formatClaudeOpus5UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; @@ -424,6 +472,7 @@ export function normalizeClaudeCliEffort( } if ( effort === "xhigh" && + model !== "claude-fable-5-1" && model !== "claude-fable-5" && model !== "claude-opus-5" && model !== "claude-opus-4-8" && @@ -913,15 +962,17 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) + const versionUpgradeMessage = supportsClaudeFable51(parsedVersion) ? undefined - : supportsClaudeFable5(parsedVersion) - ? formatClaudeOpus5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + : supportsClaudeOpus5(parsedVersion) + ? formatClaudeFable51UpgradeMessage(parsedVersion) + : supportsClaudeFable5(parsedVersion) + ? formatClaudeOpus5UpgradeMessage(parsedVersion) + : supportsClaudeOpus48(parsedVersion) + ? formatClaudeFable5UpgradeMessage(parsedVersion) + : supportsClaudeOpus47(parsedVersion) + ? formatClaudeOpus48UpgradeMessage(parsedVersion) + : formatClaudeOpus47UpgradeMessage(parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fb336e61b21cc..f483442c5a74f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2132,6 +2132,62 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("includes Claude Fable 5.1 on supported Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + const fable51 = status.models.find((model) => model.slug === "claude-fable-5-1"); + assert.strictEqual(fable51?.name, "Claude Fable 5.1"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.257\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Fable 5.1 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-fable-5-1"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.256 is too old for Claude Fable 5.1. Upgrade to v2.1.257 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.256\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("hides Claude Fable 5 on older Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index fdcfa93354246..102940ea23743 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -43,12 +43,16 @@ describe("isLegacyModel (bundled manifest)", () => { it("keeps only the Claude 5 family out of legacy models", () => { assert.deepStrictEqual( - ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ - model, - isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model), - ]), [ - ["claude-fable-5", false], + "claude-fable-5-1", + "claude-fable-5", + "claude-opus-5", + "claude-sonnet-5", + "claude-opus-4-8", + ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model)]), + [ + ["claude-fable-5-1", false], + ["claude-fable-5", true], ["claude-opus-5", false], ["claude-sonnet-5", false], ["claude-opus-4-8", true], diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 7022ce226170c..337c32e1fda1c 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -8,6 +8,6 @@ "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], - "claudeAgent": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"] + "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"] } } diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd62..b4ab84259e680 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -177,6 +177,9 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "gpt-5.3-spark": "gpt-5.3-codex-spark", }, [CLAUDE_DRIVER_KIND]: { + fable: "claude-fable-5-1", + "fable-5.1": "claude-fable-5-1", + "claude-fable-5.1": "claude-fable-5-1", opus: "claude-opus-5", "opus-5": "claude-opus-5", "claude-opus-5.0": "claude-opus-5", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index bf54a4ea7fa0f..2b27c498d1bc5 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -152,6 +152,7 @@ describe("model slug normalization", () => { it("preserves exact custom slugs instead of expanding provider aliases", () => { const claude = ProviderDriverKind.make("claudeAgent"); + expect(normalizeModelSlug("fable", claude)).toBe("claude-fable-5-1"); expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5"); expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); }); From ef7014d851f56bb037a9da963095ffd883c7fa08 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:52:36 +0200 Subject: [PATCH 026/136] fix(preview): restore recording and macOS rendering after Electron 43 (#9001) Co-authored-by: Guillermo Casanova --- apps/desktop/src/ipc/methods/preview.ts | 15 +- apps/desktop/src/preview/Manager.test.ts | 511 +++++++++++++----- apps/desktop/src/preview/Manager.ts | 277 ++++++++-- apps/web/src/browser/HostedBrowserWebview.tsx | 6 +- apps/web/src/browser/browserRecording.test.ts | 255 +++++++-- apps/web/src/browser/browserRecording.ts | 266 ++++++--- .../browser/hostedBrowserWebviewStyle.test.ts | 22 +- .../src/browser/hostedBrowserWebviewStyle.ts | 27 +- .../components/preview/PreviewView.test.tsx | 1 + .../src/components/preview/PreviewView.tsx | 5 +- packages/contracts/src/ipc.ts | 18 +- 11 files changed, 1068 insertions(+), 335 deletions(-) diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 718ebd2a3cf3e..5229d36c31f14 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -11,7 +11,6 @@ import { DesktopPreviewConfigInputSchema, DesktopPreviewNavigateInputSchema, DesktopPreviewRecordingArtifactSchema, - DesktopPreviewRecordingSourceSchema, DesktopPreviewRecordingSaveInputSchema, DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, @@ -174,15 +173,11 @@ export const cancelPickElement = tabMethod( "desktop.ipc.preview.cancelPickElement", (manager, tabId) => manager.cancelPickElement(tabId), ); -export const startRecording = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL, - payload: DesktopPreviewTabInputSchema, - result: DesktopPreviewRecordingSourceSchema, - handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) { - const manager = yield* PreviewManager.PreviewManager; - return yield* manager.startRecording(tabId); - }), -}); +export const startRecording = tabMethod( + IpcChannels.PREVIEW_RECORDING_START_CHANNEL, + "desktop.ipc.preview.startRecording", + (manager, tabId) => manager.startRecording(tabId), +); export const stopRecording = tabMethod( IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, "desktop.ipc.preview.stopRecording", diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 0cd85c710eaa1..75271d76386a1 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,4 +1,5 @@ import { it as effectIt } from "@effect/vitest"; +import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; @@ -123,7 +124,7 @@ const { } = vi.hoisted(() => ({ browserWindowConstructor: vi.fn(), createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), - fromId: vi.fn((_id?: number) => null), + fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), showItemInFolder: vi.fn(), @@ -209,14 +210,52 @@ interface TestCapturedPreviewImage { readonly getSize: () => { readonly width: number; readonly height: number }; } +type TestDisplayMediaHandler = ( + request: { readonly frame: { readonly frameTreeNodeId: number } | null }, + callback: (streams: { video?: unknown }) => void, +) => void; + +interface TestHostWebContents { + readonly id: number; + readonly mainFrame: { readonly frameTreeNodeId: number }; + readonly executeJavaScript: ReturnType; + readonly isDestroyed: () => boolean; + readonly session: { + readonly setDisplayMediaRequestHandler: ReturnType; + }; + readonly displayMediaHandler: () => TestDisplayMediaHandler | undefined; +} + +type TestPreviewWebContents = Electron.WebContents & { + readonly setBackgroundThrottling: ReturnType void>>; +}; + +const makeTestHostWebContents = (): TestHostWebContents => { + let handler: TestDisplayMediaHandler | undefined; + return { + id: 7, + mainFrame: { frameTreeNodeId: 7 }, + executeJavaScript: vi.fn(async () => true), + isDestroyed: () => false, + session: { + setDisplayMediaRequestHandler: vi.fn((next: TestDisplayMediaHandler) => { + handler = next; + }), + }, + displayMediaHandler: () => handler, + }; +}; + const makeTestPreviewWebContents = ( capturePage: () => Promise, id = 42, -) => - ({ + hostWebContents: TestHostWebContents = makeTestHostWebContents(), +) => { + const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>(); + return { id, - hostWebContents: { id: 7 }, - getMediaSourceId: vi.fn(() => `tab:${id}`), + mainFrame: { routingId: id }, + hostWebContents, executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", @@ -226,6 +265,7 @@ const makeTestPreviewWebContents = ( getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), + setBackgroundThrottling, isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), @@ -241,7 +281,45 @@ const makeTestPreviewWebContents = ( off: vi.fn(), }, capturePage, - }) as never; + } as unknown as TestPreviewWebContents; +}; + +/** Two ready tabs (41, 42) sharing one window, so they contend for the single display-media slot. */ +const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("unused-recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const host = makeTestHostWebContents(); + const destroyedIds = new Set(); + const makeWebContents = (id: number) => + Object.assign(makeTestPreviewWebContents(capturePage, id, host), { + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), + isDestroyed: () => destroyedIds.has(id), + }); + const webContentsById = new Map([ + [41, makeWebContents(41)], + [42, makeWebContents(42)], + ]); + fromId.mockImplementation((id) => + id === undefined ? null : (webContentsById.get(id) ?? null), + ); + yield* manager.createTab("tab_race_a"); + yield* manager.createTab("tab_race_b"); + yield* manager.registerWebview("tab_race_a", 41); + yield* manager.registerWebview("tab_race_b", 42); + const grants: Array<{ video?: unknown }> = []; + return { + host, + grants, + destroy: (id: number) => destroyedIds.add(id), + takeGrant: (frame = host.mainFrame) => + host.displayMediaHandler()?.({ frame }, (value) => { + grants.push(value); + }), + }; + }); const TEST_FAVICON = "data:image/png;base64,cG5n"; @@ -1865,7 +1943,7 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("keeps window unthrottled until the final frame capture stops", () => + effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () => withManager((manager) => Effect.gen(function* () { const setBackgroundThrottling = vi.fn(); @@ -1873,9 +1951,12 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("recording-frame"), getSize: () => ({ width: 1280, height: 720 }), })); + const host = makeTestHostWebContents(); + const firstWebContents = makeTestPreviewWebContents(capturePage, 41, host); + const secondWebContents = makeTestPreviewWebContents(capturePage, 42, host); const webContentsById = new Map([ - [41, makeTestPreviewWebContents(capturePage, 41)], - [42, makeTestPreviewWebContents(capturePage, 42)], + [41, firstWebContents], + [42, secondWebContents], ]); fromId.mockImplementation((id) => id === undefined ? null : (webContentsById.get(id) ?? null), @@ -1892,14 +1973,21 @@ describe("PreviewManager", () => { } as never); yield* manager.startRecording("tab_capture_throttling_1"); + // The first renderer takes its grant, freeing the arm slot for the second tab. + host.displayMediaHandler()?.({ frame: host.mainFrame }, () => {}); yield* manager.startRecording("tab_capture_throttling_2"); expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); yield* manager.stopRecording("tab_capture_throttling_1"); expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); yield* manager.stopRecording("tab_capture_throttling_2"); expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); }), ), ); @@ -1953,6 +2041,48 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("rolls back window throttling when a recorded guest cannot be unthrottled", () => + withManager((manager) => + Effect.gen(function* () { + const setWindowBackgroundThrottling = vi.fn(); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const wc = makeTestPreviewWebContents(capturePage); + fromId.mockReturnValue(wc); + + yield* manager.createTab("tab_guest_throttling_failure"); + yield* manager.registerWebview("tab_guest_throttling_failure", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { setBackgroundThrottling: setWindowBackgroundThrottling }, + } as never); + + wc.setBackgroundThrottling.mockImplementationOnce(() => { + throw new Error("guest throttling update failed"); + }); + const failedStart = yield* Effect.exit( + manager.startRecording("tab_guest_throttling_failure"), + ); + expect(Exit.isFailure(failedStart)).toBe(true); + expect(setWindowBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + + yield* manager.startRecording("tab_guest_throttling_failure"); + yield* manager.stopRecording("tab_guest_throttling_failure"); + expect(setWindowBackgroundThrottling.mock.calls).toEqual([ + [false], + [true], + [false], + [true], + ]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true]]); + }), + ), + ); + effectIt.effect("does not publish a replacement window when capture reconciliation fails", () => withManager((manager) => Effect.gen(function* () { @@ -2031,9 +2161,10 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("recording-frame"), getSize: () => ({ width: 1280, height: 720 }), })); + const host = makeTestHostWebContents(); const webContentsById = new Map([ - [42, makeTestPreviewWebContents(capturePage, 42)], - [43, makeTestPreviewWebContents(capturePage, 43)], + [42, makeTestPreviewWebContents(capturePage, 42, host)], + [43, makeTestPreviewWebContents(capturePage, 43, host)], ]); fromId.mockImplementation((id) => id === undefined ? null : (webContentsById.get(id) ?? null), @@ -2065,6 +2196,10 @@ describe("PreviewManager", () => { yield* Effect.yieldNow; yield* Effect.yieldNow; + const grants: Array<{ video?: unknown }> = []; + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value)); + expect(grants).toEqual([{}]); + yield* manager.setMainWindow({ isDestroyed: () => false, once: vi.fn(), @@ -2075,7 +2210,60 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("returns native media sources for concurrent preview recordings", () => + effectIt.effect("does not arm recording after the main window closes during warmup", () => + withManager((manager) => + Effect.gen(function* () { + let closeMainWindow: (() => void) | undefined; + let finishWarmup!: (image: TestCapturedPreviewImage) => void; + let markWarmupStarted!: () => void; + const warmupStarted = new Promise((resolve) => { + markWarmupStarted = resolve; + }); + const capturedImage = { + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + }; + const capturePage = vi.fn( + () => + new Promise((resolve) => { + markWarmupStarted(); + finishWarmup = resolve; + }), + ); + const host = makeTestHostWebContents(); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42, host)); + + yield* manager.createTab("tab_window_close_warmup"); + yield* manager.registerWebview("tab_window_close_warmup", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closeMainWindow = listener; + }), + webContents: { setBackgroundThrottling: vi.fn() }, + } as never); + + const start = yield* manager + .startRecording("tab_window_close_warmup") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => warmupStarted); + closeMainWindow?.(); + finishWarmup(capturedImage); + + const exit = yield* Fiber.await(start); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewMainWindowClosedError", + tabId: "tab_window_close_warmup", + }); + } + expect(host.session.setDisplayMediaRequestHandler).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("grants each concurrent preview recording its own tab frame", () => withManager((manager) => Effect.gen(function* () { const firstJpeg = Buffer.from("first-recording-frame"); @@ -2090,6 +2278,8 @@ describe("PreviewManager", () => { })); const firstSendCommand = vi.fn(async () => undefined); const secondSendCommand = vi.fn(async () => undefined); + // Both webviews live in the same window, so they share one display-media handler. + const host = makeTestHostWebContents(); const makeWebContents = ( id: number, capturePage: typeof firstCapturePage, @@ -2097,8 +2287,8 @@ describe("PreviewManager", () => { ) => ({ id, - hostWebContents: { id: 7 }, - getMediaSourceId: vi.fn(() => `tab:${id}`), + mainFrame: { routingId: id }, + hostWebContents: host, executeJavaScript: vi.fn(async () => id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 }, ), @@ -2110,6 +2300,7 @@ describe("PreviewManager", () => { getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), + setBackgroundThrottling: vi.fn(), isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), @@ -2137,15 +2328,19 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_2"); yield* manager.registerWebview("tab_1", 41); yield* manager.registerWebview("tab_2", 42); - const sources = yield* Effect.all( - [manager.startRecording("tab_1"), manager.startRecording("tab_2")], - { concurrency: 2 }, - ); - expect(sources).toEqual([ - { sourceId: "tab:41", width: 800, height: 600 }, - { sourceId: "tab:42", width: 390, height: 844 }, - ]); + const grants: Array<{ video?: unknown }> = []; + const takeGrant = () => + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => { + grants.push(value); + }); + + yield* manager.startRecording("tab_1"); + takeGrant(); + yield* manager.startRecording("tab_2"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 41 } }, { video: { routingId: 42 } }]); + expect(firstCapturePage).toHaveBeenCalledOnce(); expect(secondCapturePage).toHaveBeenCalledOnce(); expect(firstSendCommand).not.toHaveBeenCalledWith( @@ -2165,121 +2360,158 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("continues native recording when the source warmup fails", () => + effectIt.effect("requests display media with a fresh renderer gesture", () => withManager((manager) => Effect.gen(function* () { - const capturePage = vi.fn(async () => { - throw new Error("source is not ready"); - }); - const getMediaSourceId = vi.fn(() => "tab:42"); - const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { - executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), - getMediaSourceId, - }); - fromId.mockReturnValue(webContents); + const { host, takeGrant } = yield* setupRecordingRaceTabs(manager); - yield* manager.createTab("tab_recording_warmup_failure"); - yield* manager.registerWebview("tab_recording_warmup_failure", 42); + yield* manager.startRecording("tab_race_a"); + + expect(host.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER), + true, + ); + expect(host.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining("tab_race_a"), + true, + ); + takeGrant(); + yield* manager.stopRecording("tab_race_a"); + }), + ), + ); - expect(yield* manager.startRecording("tab_recording_warmup_failure")).toEqual({ - sourceId: "tab:42", - width: 1280, - height: 720, + // Runs on the real clock: an earlier queueing design only settled under TestClock and stalled the + // losing start forever in the desktop app. + effectIt.live("settles both starts when two tabs race for the capture stream", () => + withManager((manager) => + Effect.gen(function* () { + const { host, grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + const exits = yield* Effect.all( + [ + Effect.exit(manager.startRecording("tab_race_a")), + Effect.exit(manager.startRecording("tab_race_b")), + ], + { concurrency: 2 }, + ); + + const [exitA, exitB] = exits; + // Exactly one start owns the stream; the other fails fast instead of hanging. + expect(exits.filter(Exit.isSuccess)).toHaveLength(1); + const loserExit = Exit.isSuccess(exitA) ? exitB : exitA; + if (Exit.isSuccess(loserExit)) return; + expect(Option.getOrThrow(Cause.findErrorOption(loserExit.cause))).toMatchObject({ + _tag: "PreviewRecordingArmConflictError", }); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(getMediaSourceId).toHaveBeenCalledOnce(); - yield* manager.stopRecording("tab_recording_warmup_failure"); + // The single grant goes to the tab that actually won the slot, never the other one. + takeGrant(); + expect(grants).toEqual([{ video: { routingId: Exit.isSuccess(exitA) ? 41 : 42 } }]); + expect(host.session.setDisplayMediaRequestHandler).toHaveBeenCalledOnce(); }), ), ); - effectIt.effect("reports invalid native recording dimensions as a structured error", () => + effectIt.effect("releases an armed slot that the renderer never redeemed", () => withManager((manager) => Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("unused-recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - const getMediaSourceId = vi.fn(() => "tab:42"); - const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { - executeJavaScript: vi.fn(async () => ({ width: 0, height: 720 })), - getMediaSourceId, + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + const blocked = yield* Effect.exit(manager.startRecording("tab_race_b")); + if (Exit.isSuccess(blocked)) throw new Error("expected the second tab to be refused"); + expect(Option.getOrThrow(Cause.findErrorOption(blocked.cause))).toMatchObject({ + _tag: "PreviewRecordingArmConflictError", + tabId: "tab_race_b", + armedTabId: "tab_race_a", }); - fromId.mockReturnValue(webContents); - yield* manager.createTab("tab_invalid_recording_size"); - yield* manager.registerWebview("tab_invalid_recording_size", 42); - const exit = yield* Effect.exit(manager.startRecording("tab_invalid_recording_size")); + // Nothing ever captured the armed tab, so the slot goes stale and stops blocking starts. + yield* TestClock.adjust(10_000); + yield* manager.startRecording("tab_race_b"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 42 } }]); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) return; - expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "PreviewRecordingSourceSizeUnavailableError", - tabId: "tab_invalid_recording_size", - webContentsId: 42, - }); - expect(getMediaSourceId).not.toHaveBeenCalled(); + yield* manager.stopRecording("tab_race_a"); + yield* manager.stopRecording("tab_race_b"); }), ), ); - effectIt.effect("keeps a newer recording lease when an earlier start fails", () => + effectIt.effect("denies a display-media request that arrives after the arm went stale", () => withManager((manager) => Effect.gen(function* () { - const setBackgroundThrottling = vi.fn(); - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("unused-recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - let markMeasurementStarted!: () => void; - const measurementStarted = new Promise((resolve) => { - markMeasurementStarted = resolve; - }); - let rejectFirstMeasurement!: (error: Error) => void; - const executeJavaScript = vi - .fn<(expression: string, userGesture?: boolean) => Promise>() - .mockImplementationOnce(() => { - markMeasurementStarted(); - return new Promise((_, reject) => { - rejectFirstMeasurement = reject; - }); - }) - .mockResolvedValue({ width: 1280, height: 720 }); - const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { - executeJavaScript, + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + yield* TestClock.adjust(10_000); + // The handler cannot read a clock, so the expiry fiber must have dropped the frame. + takeGrant(); + expect(grants).toEqual([{}]); + + yield* manager.stopRecording("tab_race_a"); + }), + ), + ); + + effectIt.effect("only lets the host frame that armed a recording claim its stream", () => + withManager((manager) => + Effect.gen(function* () { + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + takeGrant({ frameTreeNodeId: 999 }); + takeGrant(); + + expect(grants).toEqual([{}, { video: { routingId: 41 } }]); + yield* manager.stopRecording("tab_race_a"); + }), + ), + ); + + effectIt.effect("reclaims the arm slot from a destroyed webContents", () => + withManager((manager) => + Effect.gen(function* () { + const { grants, takeGrant, destroy } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + destroy(41); + yield* manager.startRecording("tab_race_b"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 42 } }]); + + yield* manager.stopRecording("tab_race_b"); + }), + ), + ); + + effectIt.effect("continues native recording when the source warmup fails", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => { + throw new Error("source is not ready"); + }); + const host = makeTestHostWebContents(); + const webContents = Object.assign(makeTestPreviewWebContents(capturePage, 42, host), { + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), }); fromId.mockReturnValue(webContents); - yield* manager.createTab("tab_recording_start_race"); - yield* manager.registerWebview("tab_recording_start_race", 42); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling }, - } as never); + yield* manager.createTab("tab_recording_warmup_failure"); + yield* manager.registerWebview("tab_recording_warmup_failure", 42); - const firstStart = yield* manager - .startRecording("tab_recording_start_race") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => measurementStarted); - const secondStart = yield* manager - .startRecording("tab_recording_start_race") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - expect(executeJavaScript).toHaveBeenCalledOnce(); + yield* manager.startRecording("tab_recording_warmup_failure"); + expect(capturePage).toHaveBeenCalledTimes(2); - rejectFirstMeasurement(new Error("first measurement failed")); - const firstExit = yield* Fiber.await(firstStart); - expect(Exit.isFailure(firstExit)).toBe(true); - expect(yield* Fiber.join(secondStart)).toEqual({ - sourceId: "tab:42", - width: 1280, - height: 720, - }); + // The armed tab answers exactly one display-media request, then further requests are denied. + const handler = host.displayMediaHandler(); + const streams: Array<{ video?: unknown }> = []; + handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); + handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); + expect(streams).toEqual([{ video: { routingId: 42 } }, {}]); - yield* manager.stopRecording("tab_recording_start_race"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false], [true]]); + yield* manager.stopRecording("tab_recording_warmup_failure"); }), ), ); @@ -2287,25 +2519,26 @@ describe("PreviewManager", () => { effectIt.effect("serializes recording source acquisition with webview replacement", () => withManager((manager) => Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ + const capturedImage = { toJPEG: () => Buffer.from("unused-recording-frame"), getSize: () => ({ width: 1280, height: 720 }), - })); - let markMeasurementStarted!: () => void; - const measurementStarted = new Promise((resolve) => { - markMeasurementStarted = resolve; - }); - let finishMeasurement!: (size: { readonly width: number; readonly height: number }) => void; - const executeJavaScript = vi.fn( - () => - new Promise<{ readonly width: number; readonly height: number }>((resolve) => { - markMeasurementStarted(); - finishMeasurement = resolve; - }), - ); - const initialWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 42), { - executeJavaScript, - }); + }; + let markWarmupStarted!: () => void; + const warmupStarted = new Promise((resolve) => { + markWarmupStarted = resolve; + }); + let finishWarmup!: (image: TestCapturedPreviewImage) => void; + const capturePage = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + markWarmupStarted(); + finishWarmup = resolve; + }), + ) + .mockResolvedValue(capturedImage); + const initialWebContents = makeTestPreviewWebContents(capturePage, 42); const replacementOn = vi.fn(); const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { on: replacementOn, @@ -2321,22 +2554,25 @@ describe("PreviewManager", () => { const start = yield* manager .startRecording("tab_recording_replacement_race") .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => measurementStarted); + yield* Effect.promise(() => warmupStarted); const replacement = yield* manager .registerWebview("tab_recording_replacement_race", 43) .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.yieldNow; expect(replacementOn).not.toHaveBeenCalled(); - finishMeasurement({ width: 1280, height: 720 }); - expect(yield* Fiber.join(start)).toEqual({ - sourceId: "tab:42", - width: 1280, - height: 720, - }); + finishWarmup(capturedImage); + yield* Fiber.join(start); yield* Fiber.join(replacement); expect(replacementOn).toHaveBeenCalled(); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); yield* manager.stopRecording("tab_recording_replacement_race"); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([ + [false], + [true], + ]); }), ), ); @@ -2345,7 +2581,9 @@ describe("PreviewManager", () => { withManager((manager) => Effect.gen(function* () { const setBackgroundThrottling = vi.fn(); - const mainWindowWebContents = { setBackgroundThrottling }; + const mainWindowWebContents = Object.assign(makeTestHostWebContents(), { + setBackgroundThrottling, + }); const jpeg = Buffer.from("shared-preview-frame"); const capturePage = vi.fn(async () => ({ toJPEG: () => jpeg, @@ -2353,8 +2591,8 @@ describe("PreviewManager", () => { })); fromId.mockReturnValue({ id: 42, + mainFrame: { routingId: 42 }, hostWebContents: mainWindowWebContents, - getMediaSourceId: vi.fn(() => "tab:42"), executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", @@ -2364,6 +2602,7 @@ describe("PreviewManager", () => { getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), + setBackgroundThrottling: vi.fn(), isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 7d11b2614c1e0..8ee312110d863 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -5,6 +5,7 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ +import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, DesktopPreviewAutomationStatus, @@ -16,7 +17,6 @@ import type { PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, - DesktopPreviewRecordingSource, DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, PreviewAutomationClickInput, @@ -109,8 +109,8 @@ const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_SCREENSHOT_WIDTH = 1280; -const RECORDING_SOURCE_SIZE_EXPRESSION = - "({ width: Math.round(globalThis.innerWidth), height: Math.round(globalThis.innerHeight) })"; +/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ +const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; @@ -122,6 +122,8 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; +const requestRecordingCaptureExpression = (tabId: string): string => + `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`; const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { colorScheme: "light", @@ -390,6 +392,7 @@ type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet; + readonly unthrottledWebContentsIds: ReadonlySet; readonly lastPictureInPictureFrame: Buffer | null; } @@ -400,6 +403,14 @@ interface PictureInPictureSession { readonly initializationScope: Scope.Closeable; } +/** The tab whose frame the next `getDisplayMedia()` request is allowed to capture. */ +interface PendingRecording { + readonly tabId: string; + readonly webContents: Electron.WebContents; + readonly requestingFrameTreeNodeId: number; + readonly armedAtMillis: number; +} + interface PickSession { readonly cancel: Effect.Effect; } @@ -591,6 +602,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const pictureInPictureAspectRatiosRef = yield* Ref.make>(new Map()); const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); const closingTabIdsRef = yield* Ref.make>(new Set()); + // Tab recording uses `setDisplayMediaRequestHandler` because Electron's legacy + // `getMediaSourceId` + `chromeMediaSource: "tab"` capture path was removed upstream + // (electron#44618) and now always rejects with NotAllowedError. + let pendingRecording: PendingRecording | null = null; + const displayMediaHandlerSessions = new WeakSet(); let frameCaptureWindowOpen = true; let currentMainWindow: BrowserWindow | undefined; let mainWindowCleanupFiber: Fiber.Fiber | undefined; @@ -665,6 +681,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (Option.isNone(mainWindow)) return; yield* setWindowBackgroundThrottling(mainWindow.value, enabled); }); + const setFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( + wc: Electron.WebContents, + enabled: boolean, + ) { + if (wc.isDestroyed()) return; + yield* attempt( + { + operation: "frameCapture.setBackgroundThrottling", + webContentsId: wc.id, + }, + () => wc.setBackgroundThrottling(enabled), + ); + }); + const restoreFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( + webContentsIds: ReadonlySet, + ) { + yield* Effect.forEach( + webContentsIds, + (webContentsId) => { + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return Effect.void; + return setFrameCaptureWebContentsBackgroundThrottling(wc, true).pipe( + Effect.retry({ times: 2 }), + Effect.catch((error) => + Effect.logWarning("Failed to restore preview webview frame capture throttling.", { + webContentsId, + error, + }), + ), + ); + }, + { concurrency: "unbounded", discard: true }, + ); + }); + const keepFrameCaptureWebContentsUnthrottled = Effect.fnUntraced(function* ( + tabId: string, + wc: Electron.WebContents, + ) { + yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => { + const current = sessions.get(tabId); + if (!current || current.unthrottledWebContentsIds.has(wc.id)) { + return Effect.succeed([undefined, sessions] as const); + } + return setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( + Effect.map( + () => + [ + undefined, + replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...current, + unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), + }); + }), + ] as const, + ), + ); + }); + }); const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* ( tabId: string, consumer: FrameCaptureConsumer, @@ -694,6 +769,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const remainingSessions = replaceMap(sessions, (copy) => { copy.delete(tabId); }); + yield* restoreFrameCaptureWebContentsBackgroundThrottling( + current.unthrottledWebContentsIds, + ); if (remainingSessions.size === 0) { yield* setFrameCaptureBackgroundThrottling(true).pipe( Effect.retry({ times: 2 }), @@ -713,6 +791,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const stopAllRecordings = Effect.fn("PreviewManager.stopAllRecordings")(function* () { + pendingRecording = null; const sessions = yield* SynchronizedRef.get(frameCaptureSessionsRef); yield* Effect.forEach(sessions.keys(), (tabId) => stopFrameCapture(tabId, "recording"), { concurrency: "unbounded", @@ -1889,6 +1968,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; + clearPendingRecording(tabId); yield* Effect.all( [ cancelPickElement(tabId), @@ -1980,6 +2060,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); const currentAttachment = attached.get(webContentsId); + yield* keepFrameCaptureWebContentsUnthrottled(tabId, wc); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { // The guest we already own re-announced itself, so nothing about the tab // changed. Only push its zoom back down — Chromium may have just handed @@ -1996,6 +2077,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ? tab.webContentsId : null; if (replacedWebContentsId !== null) { + // The replaced guest can no longer redeem a display-media grant. + clearPendingRecording(tabId); yield* Effect.all( [ detachControlSession(replacedWebContentsId), @@ -2728,7 +2811,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { // Recording keeps only the activity lease. Picture-in-picture owns the // capturePage loop and tolerates transient compositor warmup failures. - yield* requireWebContents(tabId); const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe( Effect.andThen(capturePreviewFrame(tabId)), Effect.catch((error) => @@ -2749,11 +2831,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { return yield* new PreviewTabNotFoundError({ tabId }); } + const wc = yield* requireWebContents(tabId); const current = sessions.get(tabId); if (current) { if (current.consumers.has(consumer)) { return [false, sessions] as const; } + if (!current.unthrottledWebContentsIds.has(wc.id)) { + yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false); + } let scope = current.scope; if (consumer === "picture-in-picture" && scope === null) { scope = yield* Scope.fork(parentScope, "sequential"); @@ -2766,6 +2852,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ...current, scope, consumers: new Set([...current.consumers, consumer]), + unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), }); }), ] as const; @@ -2773,6 +2860,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (sessions.size === 0) { yield* setFrameCaptureBackgroundThrottling(false); } + yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( + Effect.onError(() => + sessions.size === 0 + ? setFrameCaptureBackgroundThrottling(true).pipe(Effect.ignore) + : Effect.void, + ), + ); const scope = consumer === "picture-in-picture" ? yield* Scope.fork(parentScope, "sequential") : null; if (scope !== null) { @@ -2784,6 +2878,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function copy.set(tabId, { scope, consumers: new Set([consumer]), + unthrottledWebContentsIds: new Set([wc.id]), lastPictureInPictureFrame: null, }); }), @@ -3099,6 +3194,83 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* Effect.failCause(initializationExit.cause); }); + /** Only drops the armed target when it still belongs to `tabId`, so tabs cannot clobber each other. */ + const clearPendingRecording = (tabId: string) => { + if (pendingRecording?.tabId === tabId) pendingRecording = null; + }; + + /** + * Claims the single arm slot for `tabId`. A display-media request carries no tab identity, so the + * slot is exclusive: a second tab arming before the first request lands would redirect the first + * renderer's stream. Rather than queue (which can only ever stall a start), a colliding start + * fails fast and the renderer can retry. An arm the renderer never redeemed goes stale after + * `RECORDING_ARM_GRACE_MS` so it cannot hold the slot forever. + */ + const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( + tabId: string, + wc: Electron.WebContents, + requestingFrameTreeNodeId: number, + ) { + const now = yield* Clock.currentTimeMillis; + const previous = pendingRecording; + if ( + previous !== null && + previous.tabId !== tabId && + !previous.webContents.isDestroyed() && + now - previous.armedAtMillis < RECORDING_ARM_GRACE_MS + ) { + return yield* new PreviewRecordingArmConflictError({ + tabId, + webContentsId: wc.id, + armedTabId: previous.tabId, + }); + } + const armed: PendingRecording = { + tabId, + webContents: wc, + requestingFrameTreeNodeId, + armedAtMillis: now, + }; + pendingRecording = armed; + // The handler callback is sync and cannot read a clock, so expiry is driven from here. + // Identity compare: a re-arm replaces the object, and this fiber must not clobber it. + yield* Effect.forkIn( + Effect.sleep(RECORDING_ARM_GRACE_MS).pipe( + Effect.andThen( + Effect.sync(() => { + if (pendingRecording === armed) pendingRecording = null; + }), + ), + ), + parentScope, + ); + }); + + // Installed once per session: answers the renderer's `getDisplayMedia()` with the tab that + // `startRecording` armed, and denies anything else so pages cannot capture on their own. + const installDisplayMediaRequestHandler = (session: Session) => { + if (displayMediaHandlerSessions.has(session)) return; + displayMediaHandlerSessions.add(session); + session.setDisplayMediaRequestHandler((request, callback) => { + const armed = pendingRecording; + if (!armed) { + callback({}); + return; + } + if (armed.webContents.isDestroyed()) { + pendingRecording = null; + callback({}); + return; + } + if (request.frame?.frameTreeNodeId !== armed.requestingFrameTreeNodeId) { + callback({}); + return; + } + pendingRecording = null; + callback({ video: armed.webContents.mainFrame }); + }); + }; + const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { return yield* new PreviewTabNotFoundError({ tabId }); @@ -3112,31 +3284,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (requestWebContents === null) { return yield* new PreviewMainWindowClosedError({ tabId }); } - const measuredSize = yield* attemptPromise( - { - operation: "recording.measureSource", - tabId, - webContentsId: wc.id, - }, - () => wc.executeJavaScript(RECORDING_SOURCE_SIZE_EXPRESSION, true), - ); - if ( - typeof measuredSize !== "object" || - measuredSize === null || - !("width" in measuredSize) || - !("height" in measuredSize) || - typeof measuredSize.width !== "number" || - typeof measuredSize.height !== "number" || - !Number.isInteger(measuredSize.width) || - !Number.isInteger(measuredSize.height) || - measuredSize.width <= 0 || - measuredSize.height <= 0 - ) { - return yield* new PreviewRecordingSourceSizeUnavailableError({ - tabId, - webContentsId: wc.id, - }); - } yield* attemptPromise( { operation: "recording.warmSource", @@ -3152,25 +3299,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function webContentsId: wc.id, }); } - const sourceId = yield* attempt( + if (!frameCaptureWindowOpen || requestWebContents.isDestroyed()) { + return yield* new PreviewMainWindowClosedError({ tabId }); + } + installDisplayMediaRequestHandler(requestWebContents.session); + yield* armPendingRecording(tabId, wc, requestWebContents.mainFrame.frameTreeNodeId); + const captureRequested = yield* attemptPromise( { - operation: "recording.getMediaSourceId", + operation: "recording.requestCapture", tabId, - webContentsId: wc.id, + webContentsId: requestWebContents.id, }, - () => wc.getMediaSourceId(requestWebContents), + () => + requestWebContents.executeJavaScript(requestRecordingCaptureExpression(tabId), true), ); - return { - sourceId, - width: measuredSize.width, - height: measuredSize.height, - } satisfies DesktopPreviewRecordingSource; - }).pipe(Effect.onError(() => stopFrameCapture(tabId, "recording").pipe(Effect.ignore))), + if (captureRequested !== true) { + return yield* new PreviewRecordingCaptureUnavailableError({ + tabId, + webContentsId: requestWebContents.id, + }); + } + }).pipe( + Effect.onError(() => { + clearPendingRecording(tabId); + return stopFrameCapture(tabId, "recording").pipe(Effect.ignore); + }), + ), ); }); const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { - yield* withTabLifecycleLock(tabId, stopFrameCapture(tabId, "recording")); + // Clearing runs under the tab lock so it cannot land before an in-flight start arms. + yield* withTabLifecycleLock( + tabId, + Effect.suspend(() => { + clearPendingRecording(tabId); + return stopFrameCapture(tabId, "recording"); + }), + ); }); const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( @@ -3980,12 +4146,28 @@ export class PreviewMainWindowClosedError extends Schema.TaggedErrorClass()( - "PreviewRecordingSourceSizeUnavailableError", - { tabId: Schema.String, webContentsId: Schema.Number }, +export class PreviewRecordingArmConflictError extends Schema.TaggedErrorClass()( + "PreviewRecordingArmConflictError", + { + tabId: Schema.String, + webContentsId: Schema.Number, + armedTabId: Schema.String, + }, ) { override get message(): string { - return `Preview media source dimensions are unavailable for tab ${this.tabId}`; + return `Preview tab ${this.armedTabId} is still claiming the capture stream, so recording could not start for tab ${this.tabId}`; + } +} + +export class PreviewRecordingCaptureUnavailableError extends Schema.TaggedErrorClass()( + "PreviewRecordingCaptureUnavailableError", + { + tabId: Schema.String, + webContentsId: Schema.Number, + }, +) { + override get message(): string { + return `Preview recording capture is unavailable for tab ${this.tabId} in WebContents ${this.webContentsId}`; } } @@ -4196,7 +4378,8 @@ export const PreviewManagerError = Schema.Union([ PreviewWebContentsNotFoundError, PreviewWebviewNotInitializedError, PreviewMainWindowClosedError, - PreviewRecordingSourceSizeUnavailableError, + PreviewRecordingArmConflictError, + PreviewRecordingCaptureUnavailableError, PreviewOperationError, PreviewArtifactPathOutsideDirectoryError, PreviewArtifactImageLoadError, @@ -4274,9 +4457,7 @@ export class PreviewManager extends Context.Service< readonly copyArtifactToClipboard: (path: string) => Effect.Effect; readonly openPictureInPicture: (tabId: string) => Effect.Effect; readonly closePictureInPicture: (tabId: string) => Effect.Effect; - readonly startRecording: ( - tabId: string, - ) => Effect.Effect; + readonly startRecording: (tabId: string) => Effect.Effect; readonly stopRecording: (tabId: string) => Effect.Effect; readonly saveRecording: ( tabId: string, diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 74db764005c09..77c65264aa946 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; import { useActiveBrowserRecordingTabIds } from "./browserRecording"; @@ -241,6 +241,10 @@ export function HostedBrowserWebview(props: { const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, renderingActive, + // Electron 43 can permanently blank a macOS webview after `visibility: hidden`. + // Inactive macOS guests intentionally remain paintable offscreen; other platforms still + // suspend them, and automation continues to see the macOS guests as inactive. + keepPaintableWhenInactive: isMacPlatform(navigator.platform), cornerRadius: presentation.cornerRadius, rect: lastRect, hiddenSize, diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index f45a69c601c90..49145f314e98c 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -1,37 +1,57 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const { clientSettings, events, getUserMedia, registrySet, save, startScreencast, stopScreencast } = - vi.hoisted(() => { - const events: string[] = []; - return { - clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, - events, - getUserMedia: vi.fn(), - registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet }) => { - events.push( - value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, - ); - }), - save: vi.fn(async (tabId: string) => ({ - id: "recording-test", - tabId, - path: "/tmp/recording-test.webm", - mimeType: "video/webm" as const, - sizeBytes: 0, - createdAt: "2026-06-26T00:00:00.000Z", - })), - startScreencast: vi.fn(async (tabId: string) => { - events.push("start-screencast"); - return { sourceId: `source:${tabId}`, width: 1000, height: 620 }; - }), - stopScreencast: vi.fn(async () => undefined), - }; - }); +const { + clientSettings, + events, + getDisplayMedia, + registrySet, + requestDisplayMediaCapture, + save, + startScreencast, + stopScreencast, +} = vi.hoisted(() => { + const events: string[] = []; + return { + clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, + events, + getDisplayMedia: vi.fn(), + requestDisplayMediaCapture: vi.fn((_tabId: string) => undefined), + registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet }) => { + events.push( + value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, + ); + }), + save: vi.fn(async (tabId: string) => ({ + id: "recording-test", + tabId, + path: "/tmp/recording-test.webm", + mimeType: "video/webm" as const, + sizeBytes: 0, + createdAt: "2026-06-26T00:00:00.000Z", + })), + startScreencast: vi.fn(async (_tabId: string) => { + events.push("start-screencast"); + }), + stopScreencast: vi.fn(async () => undefined), + }; +}); vi.mock("~/components/preview/previewBridge", () => ({ previewBridge: { - recording: { onFrame: vi.fn(), save, startScreencast, stopScreencast }, + recording: { + onFrame: vi.fn(), + save, + startScreencast: async (tabId: string) => { + await startScreencast(tabId); + requestDisplayMediaCapture(tabId); + }, + stopScreencast, + }, }, })); @@ -50,6 +70,7 @@ import { BrowserRecordingCaptureTimeoutError, BrowserRecordingConflictError, BrowserRecordingFormatUnavailableError, + BrowserRecordingStartCancelledError, findActiveBrowserRecordingRuntimeTabId, readActiveBrowserRecordingTabIds, readActiveBrowserRecordingTargets, @@ -122,10 +143,16 @@ describe("browser recording", () => { }); vi.stubGlobal("cancelAnimationFrame", vi.fn()); vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); - getUserMedia.mockResolvedValue({ + getDisplayMedia.mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }], }); - vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); + requestDisplayMediaCapture.mockImplementation((tabId: string) => { + const trigger = Reflect.get(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER); + if (typeof trigger !== "function" || trigger(tabId) !== true) { + throw new Error(`No pending display-media capture for ${tabId}.`); + } + }); + vi.stubGlobal("navigator", { mediaDevices: { getDisplayMedia } }); useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); }); @@ -142,13 +169,20 @@ describe("browser recording", () => { expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); }); + it("routes gesture-free starts through the desktop capture trigger", async () => { + await startBrowserRecording("automation-recording-tab"); + + expect(requestDisplayMediaCapture).toHaveBeenCalledWith("automation-recording-tab"); + expect(getDisplayMedia).toHaveBeenCalledOnce(); + await stopBrowserRecording("automation-recording-tab"); + }); + it("paints and holds a hidden browser surface for the recording lifetime", async () => { startScreencast.mockImplementationOnce(async (tabId: string) => { expect(animationFrameCount).toBe(2); expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBe(1); - return { sourceId: `source:${tabId}`, width: 1000, height: 620 }; }); - getUserMedia.mockImplementationOnce(async () => { + getDisplayMedia.mockImplementationOnce(async () => { expect(animationFrameCount).toBe(2); expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); return { getTracks: () => [{ stop: vi.fn() }] }; @@ -178,26 +212,16 @@ describe("browser recording", () => { await stopBrowserRecording("hidden-window-tab"); }); - it("records the native tab stream at the preview's current dimensions", async () => { + it("records the native tab stream armed by the main process", async () => { const stopTrack = vi.fn(); const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream; - getUserMedia.mockResolvedValueOnce(stream); + getDisplayMedia.mockResolvedValueOnce(stream); await startBrowserRecording("recording-tab"); - expect(getUserMedia).toHaveBeenCalledWith({ + expect(getDisplayMedia).toHaveBeenCalledWith({ audio: false, - video: { - mandatory: { - chromeMediaSource: "tab", - chromeMediaSourceId: "source:recording-tab", - minWidth: 1000, - maxWidth: 1000, - minHeight: 620, - maxHeight: 620, - maxFrameRate: 30, - }, - }, + video: { frameRate: { max: 30 } }, }); expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream); @@ -210,18 +234,16 @@ describe("browser recording", () => { await startBrowserRecording("recording-tab"); - expect(getUserMedia).toHaveBeenCalledWith({ + expect(getDisplayMedia).toHaveBeenCalledWith({ audio: false, - video: { - mandatory: expect.objectContaining({ maxFrameRate: 60 }), - }, + video: { frameRate: { max: 60 } }, }); await stopBrowserRecording("recording-tab"); }); it("stops the native stream when MediaRecorder cleanup fails", async () => { const stopTrack = vi.fn(); - getUserMedia.mockResolvedValueOnce({ + getDisplayMedia.mockResolvedValueOnce({ getTracks: () => [{ stop: stopTrack }], }); @@ -285,7 +307,7 @@ describe("browser recording", () => { }); it("releases the native capture lease when stream acquisition fails", async () => { - getUserMedia.mockRejectedValueOnce(new Error("capture failed")); + getDisplayMedia.mockRejectedValueOnce(new Error("capture failed")); await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({ operation: "capture-media-stream", @@ -300,7 +322,7 @@ describe("browser recording", () => { vi.useFakeTimers(); let finishCapture!: (stream: MediaStream) => void; const stopTrack = vi.fn(); - getUserMedia.mockImplementationOnce( + getDisplayMedia.mockImplementationOnce( () => new Promise((resolve) => { finishCapture = resolve; @@ -308,7 +330,7 @@ describe("browser recording", () => { ); const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(getUserMedia).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); const rejection = expect(startPromise).rejects.toMatchObject({ _tag: "BrowserRecordingCaptureTimeoutError", tabId: "recording-tab", @@ -356,6 +378,124 @@ describe("browser recording", () => { expect(save).toHaveBeenCalledTimes(2); }); + it("serializes display media grants for concurrent recording starts", async () => { + let finishFirstCapture!: (stream: MediaStream) => void; + const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; + getDisplayMedia + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstCapture = resolve; + }), + ) + .mockResolvedValueOnce(stream); + + const firstStart = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); + const secondStart = startBrowserRecording("recording-tab-2"); + await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); + + expect(startScreencast).toHaveBeenCalledTimes(1); + finishFirstCapture(stream); + await Promise.all([firstStart, secondStart]); + + expect(startScreencast.mock.calls).toEqual([["recording-tab"], ["recording-tab-2"]]); + expect(getDisplayMedia).toHaveBeenCalledTimes(2); + await Promise.all([ + stopBrowserRecording("recording-tab"), + stopBrowserRecording("recording-tab-2"), + ]); + }); + + it("cancels a queued recording when stopped before its media grant", async () => { + let finishFirstCapture!: (stream: MediaStream) => void; + const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; + getDisplayMedia.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstCapture = resolve; + }), + ); + + const firstStart = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); + const secondStart = startBrowserRecording("recording-tab-2"); + await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); + + const secondStop = stopBrowserRecording("recording-tab-2"); + await expect(secondStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); + await expect(secondStop).resolves.toBeNull(); + expect(startScreencast).toHaveBeenCalledTimes(1); + + finishFirstCapture(stream); + await firstStart; + await stopBrowserRecording("recording-tab"); + expect(getDisplayMedia).toHaveBeenCalledOnce(); + }); + + it("latches a stop that arrives before the start becomes queued", async () => { + let releaseDelayedPaint!: (timestamp: number) => void; + let frameId = 0; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + frameId += 1; + if (frameId === 1) releaseDelayedPaint = callback; + else callback(frameId); + return frameId; + }), + ); + let finishBlockingCapture!: (stream: MediaStream) => void; + const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; + getDisplayMedia.mockImplementationOnce( + () => + new Promise((resolve) => { + finishBlockingCapture = resolve; + }), + ); + + const delayedStart = startBrowserRecording("delayed-tab"); + await vi.waitFor(() => + expect(readActiveBrowserRecordingTabIds().has("delayed-tab")).toBe(true), + ); + const blockingStart = startBrowserRecording("blocking-tab"); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); + + const delayedStop = stopBrowserRecording("delayed-tab"); + releaseDelayedPaint(1); + + await expect(delayedStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); + await expect(delayedStop).resolves.toBeNull(); + expect(startScreencast).toHaveBeenCalledOnce(); + + finishBlockingCapture(stream); + await blockingStart; + await stopBrowserRecording("blocking-tab"); + }); + + it("finishes an uncontended pre-grant start before stopping", async () => { + const animationFrames: FrameRequestCallback[] = []; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + animationFrames.push(callback); + return animationFrames.length; + }), + ); + + const startPromise = startBrowserRecording("recording-tab"); + await vi.waitFor(() => + expect(readActiveBrowserRecordingTabIds().has("recording-tab")).toBe(true), + ); + const stopPromise = stopBrowserRecording("recording-tab"); + expect(startScreencast).not.toHaveBeenCalled(); + + animationFrames.shift()?.(1); + animationFrames.shift()?.(2); + await startPromise; + await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); + }); + it("keeps a recording reachable through its runtime id after a server epoch changes", async () => { const threadRef = { environmentId: EnvironmentId.make("environment-recording"), @@ -387,7 +527,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const firstStart = startBrowserRecording("recording-tab"); @@ -452,7 +591,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const startPromise = startBrowserRecording("recording-tab"); @@ -476,7 +614,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const firstStart = startBrowserRecording("recording-tab"); @@ -503,7 +640,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); @@ -537,7 +673,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const startPromise = startBrowserRecording("recording-tab"); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index e242f25c7d2dc..73bc2708ddf6e 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -1,8 +1,5 @@ -import type { - DesktopPreviewRecordingArtifact, - DesktopPreviewRecordingSource, - ScopedThreadRef, -} from "@t3tools/contracts"; +import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; +import type { DesktopPreviewRecordingArtifact, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; @@ -36,6 +33,17 @@ export class BrowserRecordingConflictError extends Schema.TaggedErrorClass()( + "BrowserRecordingStartCancelledError", + { + tabId: Schema.String, + }, +) { + override get message(): string { + return `Browser recording start was cancelled for tab ${this.tabId}.`; + } +} + export class BrowserRecordingFormatUnavailableError extends Schema.TaggedErrorClass()( "BrowserRecordingFormatUnavailableError", { tabId: Schema.String }, @@ -82,9 +90,21 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass; + readonly cancelBeforeGrant: () => void; + readonly setQueuedForGrant: (queued: boolean) => void; +} type BrowserRecordingLifecycle = - | { readonly phase: "starting" } + | StartingBrowserRecordingLifecycle | { readonly phase: "recording" } | { readonly phase: "stopping"; @@ -124,6 +144,53 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet { } const activeRecordings = new Map(); +let displayMediaGrantTail = Promise.resolve(); +let displayMediaGrantQueueDepth = 0; + +const makeStartingBrowserRecordingLifecycle = (): StartingBrowserRecordingLifecycle => { + let signalCancellation!: () => void; + const cancelledBeforeGrantSignal = new Promise((resolve) => { + signalCancellation = resolve; + }); + const lifecycle: StartingBrowserRecordingLifecycle = { + phase: "starting", + queuedForGrant: null, + grantStarted: false, + stopRequestedBeforeGrant: false, + cancelledBeforeGrant: false, + cancelledBeforeGrantSignal, + cancelBeforeGrant: () => { + // Queue position is unknown during paint/settings warmup. Keep the stop request so a start + // that later turns out to be contended can still be cancelled before native capture. + lifecycle.stopRequestedBeforeGrant = true; + if (lifecycle.queuedForGrant && !lifecycle.grantStarted && !lifecycle.cancelledBeforeGrant) { + lifecycle.cancelledBeforeGrant = true; + signalCancellation(); + } + }, + setQueuedForGrant: (queued) => { + lifecycle.queuedForGrant = queued; + if (queued && lifecycle.stopRequestedBeforeGrant) lifecycle.cancelBeforeGrant(); + }, + }; + return lifecycle; +}; + +const queueDisplayMediaGrant = ( + useGrant: () => Promise, +): { readonly queued: boolean; readonly result: Promise } => { + const queued = displayMediaGrantQueueDepth > 0; + displayMediaGrantQueueDepth += 1; + const result = displayMediaGrantTail.then(useGrant); + const settleGrant = () => { + displayMediaGrantQueueDepth -= 1; + }; + displayMediaGrantTail = result.then( + () => settleGrant(), + () => settleGrant(), + ); + return { queued, result }; +}; const publishActiveRecordingTabIds = (): void => { appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { @@ -184,23 +251,12 @@ const createMediaRecorder = (stream: MediaStream): MediaRecorder => { return mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); }; -const captureTabMediaStream = ( - source: DesktopPreviewRecordingSource, - frameRate: number, -): Promise => - navigator.mediaDevices.getUserMedia({ +const captureTabMediaStream = (frameRate: number): Promise => + // The desktop main process routes this request to the tab that `startScreencast` armed, so the + // stream already arrives at that tab's native size and needs no source or dimension constraints. + navigator.mediaDevices.getDisplayMedia({ audio: false, - video: { - mandatory: { - chromeMediaSource: "tab", - chromeMediaSourceId: source.sourceId, - minWidth: source.width, - maxWidth: source.width, - minHeight: source.height, - maxHeight: source.height, - maxFrameRate: frameRate, - }, - } as unknown as MediaTrackConstraints, + video: { frameRate: { max: frameRate } }, }); const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise => { @@ -216,14 +272,75 @@ const stopMediaStream = (stream: MediaStream | null): void => { for (const track of stream?.getTracks() ?? []) track.stop(); }; +interface PendingTabMediaCapture { + readonly start: () => void; +} + +const pendingTabMediaCaptures = new Map(); + +const prepareTabMediaCapture = (tabId: string, frameRate: number) => { + let acceptStream = true; + let capturedStream: MediaStream | null = null; + let resolveCapture!: (stream: MediaStream | PromiseLike) => void; + let rejectCapture!: (cause: unknown) => void; + const capturePromise = new Promise((resolve, reject) => { + resolveCapture = resolve; + rejectCapture = reject; + }).then((stream) => { + capturedStream = stream; + if (!acceptStream) { + stopMediaStream(stream); + capturedStream = null; + } + return stream; + }); + const pending: PendingTabMediaCapture = { + start: () => { + try { + // Electron invokes this callback through executeJavaScript(..., true), so even automated + // and delayed queued starts satisfy getDisplayMedia's transient-activation requirement. + resolveCapture(captureTabMediaStream(frameRate)); + } catch (cause) { + rejectCapture(cause); + } + }, + }; + pendingTabMediaCaptures.set(tabId, pending); + return { + capturePromise, + cancel: () => { + acceptStream = false; + if (capturedStream) { + stopMediaStream(capturedStream); + capturedStream = null; + } + if (pendingTabMediaCaptures.get(tabId) === pending) pendingTabMediaCaptures.delete(tabId); + void capturePromise.catch(() => undefined); + }, + }; +}; + +const triggerTabMediaCapture = (tabId: unknown): boolean => { + if (typeof tabId !== "string") return false; + const pending = pendingTabMediaCaptures.get(tabId); + if (!pending) return false; + pendingTabMediaCaptures.delete(tabId); + pending.start(); + return true; +}; + +Object.defineProperty(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, { + configurable: true, + value: triggerTabMediaCapture, +}); + const captureTabMediaStreamWithTimeout = async ( tabId: string, - source: DesktopPreviewRecordingSource, - frameRate: number, + capturePromise: Promise, ): Promise => { let acceptStream = true; let timeoutId: number | null = null; - const streamPromise = captureTabMediaStream(source, frameRate).then((stream) => { + const streamPromise = capturePromise.then((stream) => { if (!acceptStream) stopMediaStream(stream); return stream; }); @@ -382,6 +499,7 @@ export async function startBrowserRecording( const startupSettled = new Promise((resolve) => { settleStartup = resolve; }); + const startingLifecycle = makeStartingBrowserRecordingLifecycle(); const releaseSurfaceActivity = acquireBrowserSurfaceActivity(tabId); const recording: ActiveRecording = { tabId, @@ -393,7 +511,7 @@ export async function startBrowserRecording( releaseSurfaceActivity, stream: null, recorder: null, - lifecycle: { phase: "starting" }, + lifecycle: startingLifecycle, }; activeRecordings.set(tabId, recording); publishActiveRecordingTabIds(); @@ -402,23 +520,9 @@ export async function startBrowserRecording( () => getClientSettings().browserRecordingFrameRate, ); const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); - let source: DesktopPreviewRecordingSource; - try { - source = await bridge.recording.startScreencast(tabId); - } catch (cause) { - if (!isRecordingStarting(recording)) { - throw recordingStartupCancelledError(recording, cause); - } - clearActiveRecording(recording); - throw new BrowserRecordingOperationError({ - operation: "start-screencast", - tabId, - cause, - }); - } const throwIfStartupCancelled = async (): Promise => { - // A stop requested during startup should let startup finish so the - // caller receives a real artifact. Only replacement/removal cancels it. + // Once a grant starts, a stop lets startup finish so the caller receives an artifact. + // Only a contended start can be cancelled before it reaches native capture. if (activeRecordings.get(tabId) === recording) return; try { await bridge.recording.stopScreencast(tabId); @@ -434,30 +538,67 @@ export async function startBrowserRecording( } throw recordingStartupCancelledError(recording); }; - await throwIfStartupCancelled(); - try { - recording.stream = await captureTabMediaStreamWithTimeout(tabId, source, frameRate); - } catch (cause) { - const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); - if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause; - throw new BrowserRecordingOperationError({ - operation: "capture-media-stream", - tabId, - cause: - cleanupCause === undefined - ? cause - : new AggregateError( - [cause, cleanupCause], - `Browser media capture and cleanup failed for tab ${tabId}.`, - { cause }, - ), - }); - } + // The desktop process exposes one display-media grant at a time. Keep only the + // arm-to-capture handoff exclusive; acquired streams can record concurrently. + const grant = queueDisplayMediaGrant(async () => { + if (startingLifecycle.cancelledBeforeGrant) { + throw new BrowserRecordingStartCancelledError({ tabId }); + } + startingLifecycle.grantStarted = true; + await throwIfStartupCancelled(); + const capture = prepareTabMediaCapture(tabId, frameRate); + try { + await bridge.recording.startScreencast(tabId); + } catch (cause) { + capture.cancel(); + if (!isRecordingStarting(recording)) { + throw recordingStartupCancelledError(recording, cause); + } + clearActiveRecording(recording); + throw new BrowserRecordingOperationError({ + operation: "start-screencast", + tabId, + cause, + }); + } + try { + await throwIfStartupCancelled(); + } catch (cause) { + capture.cancel(); + throw cause; + } + try { + recording.stream = await captureTabMediaStreamWithTimeout(tabId, capture.capturePromise); + return recording.stream; + } catch (cause) { + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause; + throw new BrowserRecordingOperationError({ + operation: "capture-media-stream", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser media capture and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); + } + }); + startingLifecycle.setQueuedForGrant(grant.queued); + const stream = await Promise.race([ + grant.result, + startingLifecycle.cancelledBeforeGrantSignal.then(() => { + throw new BrowserRecordingStartCancelledError({ tabId }); + }), + ]); await throwIfStartupCancelled(); let recorder: MediaRecorder; try { - recorder = createMediaRecorder(recording.stream); + recorder = createMediaRecorder(stream); recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); @@ -639,6 +780,7 @@ export function stopBrowserRecording( const recording = activeRecordings.get(tabId); if (!bridge || !recording) return Promise.resolve(null); if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; + if (recording.lifecycle.phase === "starting") recording.lifecycle.cancelBeforeGrant(); const stopPromise = Promise.resolve() .then(() => finalizeBrowserRecording(bridge, recording)) diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index c2c78f4ac61c1..69216796af9fa 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -61,7 +61,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { }); }); - it("keeps an active background task paintable offscreen", () => { + it("keeps an active background task paintable behind the app", () => { const style = resolveHostedBrowserWebviewWrapperStyle({ active: false, renderingActive: true, @@ -69,6 +69,26 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { hiddenSize: { width: 1280, height: 800 }, }); + expect(style).toEqual({ + left: 0, + top: 0, + width: 1280, + height: 800, + zIndex: -1, + pointerEvents: "none", + visibility: "visible", + }); + }); + + it("keeps an inactive webview paintable without marking it as rendering-active", () => { + const style = resolveHostedBrowserWebviewWrapperStyle({ + active: false, + renderingActive: false, + keepPaintableWhenInactive: true, + rect: null, + hiddenSize: { width: 1280, height: 800 }, + }); + expect(style).toEqual({ left: HIDDEN_BROWSER_WEBVIEW_OFFSET, top: HIDDEN_BROWSER_WEBVIEW_OFFSET, diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index 1e6ff0beabe51..a59a4a8b0083f 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -21,11 +21,19 @@ export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly active: boolean; readonly renderingActive: boolean; + readonly keepPaintableWhenInactive?: boolean; readonly cornerRadius?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { - const { active, cornerRadius = 0, hiddenSize, rect, renderingActive } = input; + const { + active, + cornerRadius = 0, + hiddenSize, + keepPaintableWhenInactive = false, + rect, + renderingActive, + } = input; if (active && rect) { return { left: rect.x, @@ -38,6 +46,21 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { }; } + if (renderingActive) { + // Electron stops compositing a guest that is fully outside the window, even + // when background throttling is disabled. Keep capture-active guests inside + // the viewport but behind the app so recordings receive complete frames. + return { + left: 0, + top: 0, + width: hiddenSize.width, + height: hiddenSize.height, + zIndex: -1, + pointerEvents: "none", + visibility: "visible", + }; + } + return { left: HIDDEN_BROWSER_WEBVIEW_OFFSET, top: HIDDEN_BROWSER_WEBVIEW_OFFSET, @@ -45,6 +68,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { height: hiddenSize.height, zIndex: -1, pointerEvents: "none", - visibility: renderingActive ? "visible" : "hidden", + visibility: keepPaintableWhenInactive ? "visible" : "hidden", }; } diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 2ace29ae5c415..fd6ac25ceceda 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -149,6 +149,7 @@ vi.mock("~/state/use-atom-command", () => ({ vi.mock("~/browser/browserRecording", () => ({ findActiveBrowserRecordingRuntimeTabId: vi.fn(() => null), + isBrowserRecordingStartCancelledError: vi.fn(() => false), startBrowserRecording: vi.fn(), stopBrowserRecording: vi.fn(), useActiveBrowserRecordingTabIds: () => new Set(), diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 9a812923717ad..063314863fec9 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -55,6 +55,7 @@ import { ZoomIndicator } from "./ZoomIndicator"; import { AgentBrowserCursor } from "./AgentBrowserCursor"; import { findActiveBrowserRecordingRuntimeTabId, + isBrowserRecordingStartCancelledError, startBrowserRecording, stopBrowserRecording, useActiveBrowserRecordingTabIds, @@ -398,10 +399,12 @@ export function PreviewView({ } if (record) { void startBrowserRecording(runtimeTabId, threadRef, tabId).catch((error) => { + const description = error instanceof Error ? error.message : "An error occurred."; + if (isBrowserRecordingStartCancelledError(error)) return; toastManager.add({ type: "error", title: "Unable to start recording", - description: error instanceof Error ? error.message : "An error occurred.", + description, }); }); return; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b784792783300..7de366524713a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -734,19 +734,6 @@ export const DesktopPreviewRecordingFrameSchema: Schema.Codec = - Schema.Struct({ - sourceId: Schema.String, - width: Schema.Int.check(Schema.isGreaterThan(0)), - height: Schema.Int.check(Schema.isGreaterThan(0)), - }); - export interface DesktopPreviewRecordingArtifact { id: string; tabId: string; @@ -1173,6 +1160,9 @@ export interface DesktopBridge { preview?: DesktopPreviewBridge; } +/** Renderer callback invoked by Electron with a fresh user gesture before display-media capture. */ +export const DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER = "__t3DesktopPreviewRecordingCapture"; + export interface DesktopPreviewBridge { createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise; closeTab: (tabId: string) => Promise; @@ -1228,7 +1218,7 @@ export interface DesktopPreviewBridge { close: (tabId: string) => Promise; }; recording: { - startScreencast: (tabId: string) => Promise; + startScreencast: (tabId: string) => Promise; stopScreencast: (tabId: string) => Promise; save: ( tabId: string, From 9d1879b142a2f5d01383357646a4679d1a2bd202 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 1 Sep 2026 13:11:16 -0700 Subject: [PATCH 027/136] feat(desktop): add configurable quit shortcut confirmation (#9076) --- apps/desktop/src/preload.ts | 16 +- .../settings/DesktopClientSettings.test.ts | 2 +- apps/desktop/src/window/DesktopWindow.ts | 17 +- apps/desktop/src/window/QuitHold.test.ts | 202 ++++++++++++++---- apps/desktop/src/window/QuitHold.ts | 117 ++++++---- apps/web/src/components/QuitHoldOverlay.tsx | 33 +-- .../components/settings/SettingsPanels.tsx | 39 +++- .../src/components/settings/settingsSearch.ts | 4 +- packages/contracts/src/ipc.ts | 13 +- packages/contracts/src/settings.test.ts | 26 +++ packages/contracts/src/settings.ts | 26 ++- 11 files changed, 364 insertions(+), 131 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d1313ff2e7676..2e79f06450bfd 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -128,9 +128,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, onQuitShortcut: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { - if (state !== "down" && state !== "up") return; - listener(state); + const wrappedListener = (_event: Electron.IpcRendererEvent, hint: unknown) => { + if (typeof hint !== "object" || hint === null || !("state" in hint)) return; + if (hint.state === "up") { + listener({ state: "up" }); + return; + } + if ( + hint.state === "down" && + "mode" in hint && + (hint.mode === "hold" || hint.mode === "double-click") + ) { + listener({ state: "down", mode: hint.mode }); + } }; ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 27981a8b98288..1d0c1653f0251 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,7 +19,7 @@ const clientSettings: ClientSettings = { browserDefaultAppearance: "dark", browserRecordingFrameRate: 60, browserAutoShowFloatingPreview: false, - confirmQuit: true, + confirmQuit: "double-click", confirmThreadArchive: true, confirmThreadDelete: false, confirmThreadUnpin: false, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c7..f1155175a5f25 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -27,7 +27,7 @@ import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; -import { makeQuitHoldHandler } from "./QuitHold.ts"; +import { makeQuitShortcutHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -551,12 +551,11 @@ export const make = Effect.gen(function* () { // close-terminal shortcut can outlive the terminal that handled its first // press, so reject repeats before they reach the native window accelerator. // Deliberate presses still flow through the renderer or native menu. - // Chrome-style hold-to-quit: intercept the quit accelerator before the - // native menu sees it and only quit after the shortcut is held. The - // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. - const quitHoldHandler = makeQuitHoldHandler({ + // Intercept the quit accelerator before the native menu sees it and apply + // the configured direct, hold, or double-press behavior. + const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, - isEnabled: () => + getMode: () => runPromise( Effect.map( clientSettings.get, @@ -566,9 +565,9 @@ export const make = Effect.gen(function* () { }), ), ), - notify: (state) => { + notify: (hint) => { if (!window.isDestroyed()) { - window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); + window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); } }, quit: () => { @@ -576,7 +575,7 @@ export const make = Effect.gen(function* () { }, }); window.webContents.on("before-input-event", (event, input) => { - quitHoldHandler(event, input); + quitShortcutHandler(event, input); if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index 75fed4b08f215..c4388ad39aead 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -1,12 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { - makeQuitHoldHandler, - QUIT_DOUBLE_TAP_MS, + makeQuitShortcutHandler, + QUIT_DOUBLE_PRESS_MS, QUIT_HOLD_DURATION_MS, QUIT_HOLD_RELEASE_GRACE_MS, } from "./QuitHold.ts"; -import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; +import type { QuitHoldKeyInput } from "./QuitHold.ts"; +import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; + +const HOLD_DOWN = { state: "down", mode: "hold" } as const; +const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const; +const UP = { state: "up" } as const; function makeInput(overrides: Partial): QuitHoldKeyInput { return { @@ -22,22 +27,22 @@ function makeInput(overrides: Partial): QuitHoldKeyInput { } function makeHarness(options?: { - enabled?: boolean; + mode?: QuitConfirmationMode; platform?: NodeJS.Platform; - isEnabled?: () => Promise; + getMode?: () => Promise; }) { - const notifications: Array = []; + const notifications: Array = []; const quit = vi.fn(); - const handler = makeQuitHoldHandler({ + const handler = makeQuitShortcutHandler({ platform: options?.platform ?? "darwin", - isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), - notify: (state) => notifications.push(state), + getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")), + notify: (event) => notifications.push(event), quit, }); const preventDefault = vi.fn(); const send = async (input: QuitHoldKeyInput) => { handler({ preventDefault }, input); - // Let the isEnabled promise settle. + // Let the getMode promise settle. await Promise.resolve(); await Promise.resolve(); }; @@ -55,7 +60,7 @@ function makeHarness(options?: { return { notifications, quit, preventDefault, send, holdFor }; } -describe("makeQuitHoldHandler", () => { +describe("makeQuitShortcutHandler", () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -69,12 +74,12 @@ describe("makeQuitHoldHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); expect(harness.preventDefault).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down"]); + expect(harness.notifications).toEqual([HOLD_DOWN]); vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).not.toHaveBeenCalled(); // The watchdog dismisses the hint once the press is clearly over. - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("quits after a completed hold is released", async () => { @@ -86,7 +91,7 @@ describe("makeQuitHoldHandler", () => { expect(harness.quit).not.toHaveBeenCalled(); vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("waits for Q release when Cmd is released first", async () => { @@ -108,7 +113,7 @@ describe("makeQuitHoldHandler", () => { await harness.send(makeInput({})); await harness.holdFor(500); await harness.send(makeInput({ type: "keyUp" })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); expect(harness.quit).not.toHaveBeenCalled(); }); @@ -117,61 +122,179 @@ describe("makeQuitHoldHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); expect(harness.quit).not.toHaveBeenCalled(); }); - it("quits without showing a hint when hold-to-quit is disabled", async () => { - const harness = makeHarness({ enabled: false }); + it("quits without showing a hint in direct mode", async () => { + const harness = makeHarness({ mode: "direct" }); await harness.send(makeInput({})); expect(harness.quit).toHaveBeenCalledTimes(1); expect(harness.notifications).toEqual([]); }); - it("discards a stale isEnabled resolution from a superseded press", async () => { - // Press #1's isEnabled is still pending when the user releases and + it("honors direct mode when the key is released before its mode read settles", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolveMode?.("direct"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }); + + it("does not arm hold mode after a released key's mode read settles", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolveMode?.("hold"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it("honors a quick double press when both key releases beat their mode reads", async () => { + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; + const harness = makeHarness({ + getMode: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolvers[1]?.("double-click"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }); + + it("discards a stale mode resolution from a superseded press", async () => { + // Press #1's mode is still pending when the user releases and // presses again; its late resolution must not act for press #2. - const resolvers: Array<(enabled: boolean) => void> = []; + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; const harness = makeHarness({ - isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), + getMode: () => new Promise((resolve) => resolvers.push(resolve)), }); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - // Outside the double-tap window, so the second press starts a new hold. - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + // Outside the double-press window, so the second press starts a new hold. + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); await harness.send(makeInput({})); expect(resolvers).toHaveLength(2); - // Press #1 resolves late with "disabled" — it must not quit press #2. - resolvers[0]?.(false); + // Press #1 resolves late with "direct". It must not quit press #2. + resolvers[0]?.("direct"); await Promise.resolve(); await Promise.resolve(); expect(harness.quit).not.toHaveBeenCalled(); - // Press #2 resolves enabled and completes a full hold. - resolvers[1]?.(true); + // Press #2 resolves to hold and completes the gesture. + resolvers[1]?.("hold"); await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); await harness.send(makeInput({ type: "keyUp" })); expect(harness.quit).toHaveBeenCalledTimes(1); }); - it("quits on a quick double tap, even when the first release was never seen", async () => { - const harness = makeHarness(); + it("quits on a quick double press in double-click mode when the first release is unseen", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("keeps the double-press hint visible after key release until the window ends", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + vi.advanceTimersByTime(100); + await harness.send(makeInput({ type: "keyUp" })); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 101); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + vi.advanceTimersByTime(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); }); - it("treats two slow taps as separate presses", async () => { + it("accepts a second full shortcut after the modifier is released and pressed again", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + vi.advanceTimersByTime(100); + + await harness.send(makeInput({ key: "Meta" })); + await harness.send(makeInput({})); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("expires a delayed double-press hint from keydown rather than mode resolution", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + vi.advanceTimersByTime(100); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(100); + resolveMode?.("double-click"); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 201); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + vi.advanceTimersByTime(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("treats two slow presses as separate attempts in double-click mode", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); + }); + + it("does not treat two quick presses as a quit in hold mode", async () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); }); it("cancels the hold when another key interrupts it", async () => { @@ -180,21 +303,20 @@ describe("makeQuitHoldHandler", () => { await harness.holdFor(500); // Shift pressed mid-hold breaks the gesture... await harness.send(makeInput({ shift: true })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); // ...so later repeats past the threshold must not quit. await harness.holdFor(QUIT_HOLD_DURATION_MS); expect(harness.quit).not.toHaveBeenCalled(); }); - it("does not count an interrupted press toward a double tap", async () => { - const harness = makeHarness(); + it("does not count an interrupted press toward a double press", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); await harness.send(makeInput({ shift: true })); - // A fresh press right after the interruption starts a new hold, not a - // double-tap quit. + // A fresh press right after the interruption starts a new attempt. await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); it("ignores other shortcuts", async () => { diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index 885770accfa29..5756de64d417a 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -1,15 +1,12 @@ // @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. -// Chrome-style hold-to-quit. The quit accelerator is intercepted in -// before-input-event (which runs before the native menu accelerator), and the -// app only quits after the shortcut has been held for QUIT_HOLD_DURATION_MS -// and released. -// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap -// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application -// menu itself is untouched and quits immediately. +import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; + +// The quit accelerator is intercepted in before-input-event, which runs +// before the native menu accelerator. Quitting from the application menu is +// untouched and always quits immediately. export const QUIT_HOLD_DURATION_MS = 1200; -// A second quick tap of the shortcut is the user insisting: quit immediately. -export const QUIT_DOUBLE_TAP_MS = 500; +export const QUIT_DOUBLE_PRESS_MS = 500; // "Still held" is proven by auto-repeat keydowns, not by the absence of a // release: macOS suppresses a letter keyUp while the command key is down, so a // tap release can go completely unseen and a release-based timer would quit @@ -18,8 +15,6 @@ export const QUIT_DOUBLE_TAP_MS = 500; // auto-repeat disabled fall back to the application menu Quit action. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; -export type QuitHoldState = "down" | "up"; - export interface QuitHoldKeyInput { readonly type: string; readonly key: string; @@ -30,27 +25,29 @@ export interface QuitHoldKeyInput { readonly isAutoRepeat: boolean; } -export interface QuitHoldOptions { +export interface QuitShortcutOptions { readonly platform: NodeJS.Platform; - readonly isEnabled: () => Promise; - readonly notify: (state: QuitHoldState) => void; + readonly getMode: () => Promise; + readonly notify: (event: QuitShortcutHintEvent) => void; readonly quit: () => void; } -export function makeQuitHoldHandler( - options: QuitHoldOptions, +export function makeQuitShortcutHandler( + options: QuitShortcutOptions, ): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { const modifierKey = options.platform === "darwin" ? "meta" : "control"; let watchdog: NodeJS.Timeout | undefined; let holding = false; - // Set once isEnabled resolves true; auto-repeats may only complete the hold when armed. + let mode: QuitConfirmationMode | undefined; + let notified = false; + // Set once getMode resolves to hold; auto-repeats may only complete the hold when armed. let armed = false; let quitOnRelease = false; let heldSince = 0; let lastPressAt = 0; - // Incremented on every new press and every release/quit so a pending - // isEnabled() resolution from a superseded press cannot arm (or quit for) - // the current one. + // Incremented when a press is superseded or explicitly cancelled. A plain + // key release does not invalidate its pending mode read: direct mode and a + // completed second press must still be honored after that read settles. let generation = 0; const clearWatchdog = () => { @@ -60,19 +57,24 @@ export function makeQuitHoldHandler( } }; - const release = () => { - if (!holding) return; - const shouldNotify = armed || quitOnRelease; - generation += 1; + const release = (cancelPendingMode = true, keepDoublePressHint = false) => { + if (!holding && !notified) return; + const keepHint = keepDoublePressHint && mode === "double-click" && notified; + if (cancelPendingMode) generation += 1; holding = false; armed = false; quitOnRelease = false; + if (keepHint) return; + + mode = undefined; clearWatchdog(); - if (shouldNotify) options.notify("up"); + if (notified) { + notified = false; + options.notify({ state: "up" }); + } }; - // Dismisses any overlay first: if the quit is cancelled downstream the - // renderer must not be left with a stuck "Hold to Quit" hint. + // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. const quitNow = () => { release(); options.quit(); @@ -83,11 +85,11 @@ export function makeQuitHoldHandler( if (input.type === "keyUp") { if (key === "q") { const shouldQuit = quitOnRelease; - release(); + release(false, true); if (shouldQuit) options.quit(); } else if (key === modifierKey) { if (!quitOnRelease) { - release(); + release(false, true); } else { watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS); } @@ -104,13 +106,17 @@ export function makeQuitHoldHandler( const modifierDown = options.platform === "darwin" ? input.meta : input.control; if (!modifierDown || input.alt || input.shift || key !== "q") { + // Re-pressing the platform modifier is the first half of a second full + // quit shortcut, so it must not cancel an active double-press window. + if (key === modifierKey && !input.alt && !input.shift) return; + // Any other key (or an extra modifier) pressed mid-hold breaks the // gesture; without this the hold timer keeps running through the // interruption and the next qualifying repeat would quit early. The - // interrupted press also stops counting toward a double tap — but only + // interrupted press also stops counting toward a double press, but only // here, not in release(), which runs mid-restart on an unseen-release // re-press and must not wipe that press's own tap timestamp. - if (holding && !input.isAutoRepeat) { + if ((holding || notified) && !input.isAutoRepeat) { lastPressAt = 0; release(); } @@ -120,7 +126,7 @@ export function makeQuitHoldHandler( event.preventDefault(); if (input.isAutoRepeat) { - if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { + if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { armed = false; quitOnRelease = true; clearWatchdog(); @@ -131,28 +137,51 @@ export function makeQuitHoldHandler( const now = Date.now(); const previousPressAt = lastPressAt; lastPressAt = now; - // A fresh keydown while "holding" means the key came back down after a - // release macOS never delivered — so both branches below see real taps. - if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { - quitNow(); - return; - } - if (holding) release(); + // A fresh keydown supersedes the current physical hold or the hint kept + // alive after a detected release. + if (holding || notified) release(); generation += 1; const pressGeneration = generation; holding = true; heldSince = now; - void options.isEnabled().then( - (enabled) => { + void options.getMode().then( + (resolvedMode) => { if (generation !== pressGeneration) return; - if (!enabled) { - // Hold-to-quit disabled: a single press quits immediately. + if (resolvedMode === "direct") { quitNow(); return; } + if ( + resolvedMode === "double-click" && + previousPressAt !== 0 && + now - previousPressAt <= QUIT_DOUBLE_PRESS_MS + ) { + quitNow(); + return; + } + + if (resolvedMode === "double-click") { + const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now); + if (remainingMs <= 0) { + release(); + return; + } + mode = resolvedMode; + notified = true; + options.notify({ state: "down", mode: resolvedMode }); + watchdog = setTimeout(release, remainingMs); + return; + } + + // A hold cannot be armed after its physical press has ended. + if (!holding) return; + + mode = resolvedMode; + notified = true; + options.notify({ state: "down", mode: resolvedMode }); + armed = true; - options.notify("down"); // No auto-repeat by then means the key was released (possibly with a // suppressed keyUp) or repeat is disabled; either way, don't quit. watchdog = setTimeout(() => { diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx index 29c044015212f..091fa60c2f71b 100644 --- a/apps/web/src/components/QuitHoldOverlay.tsx +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -2,29 +2,34 @@ import { useEffect, useState } from "react"; import { isMacPlatform } from "../lib/utils"; -// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint -// from a quick tap lingers for as long as a full hold would have taken. -const HIDE_AFTER_RELEASE_MS = 1200; +// A released hold hint lingers for the original hold duration. Double-press +// hints disappear as soon as their acceptance window closes. +const HOLD_HINT_LINGER_MS = 1200; /** - * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts - * the quit accelerator and pushes press/release states; a quick tap shows - * this pill while a full hold quits the app. + * The desktop main process intercepts the quit accelerator and pushes + * press/release states while it waits for a hold or second press. */ export function QuitHoldOverlay() { - const [visible, setVisible] = useState(false); + const [visibleMode, setVisibleMode] = useState<"hold" | "double-click" | null>(null); useEffect(() => { const subscribe = window.desktopBridge?.onQuitShortcut; if (!subscribe) return; let hideTimer: number | undefined; - const unsubscribe = subscribe((state) => { + let pressedMode: "hold" | "double-click" = "hold"; + const unsubscribe = subscribe((hint) => { window.clearTimeout(hideTimer); - if (state === "down") { - setVisible(true); + if (hint.state === "down") { + pressedMode = hint.mode; + setVisibleMode(hint.mode); return; } - hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); + if (pressedMode === "double-click") { + setVisibleMode(null); + return; + } + hideTimer = window.setTimeout(() => setVisibleMode(null), HOLD_HINT_LINGER_MS); }); return () => { window.clearTimeout(hideTimer); @@ -32,15 +37,17 @@ export function QuitHoldOverlay() { }; }, []); - if (!visible) return null; + if (!visibleMode) return null; const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; + const message = + visibleMode === "hold" ? `Hold ${shortcut} to Quit` : `Press ${shortcut} again to Quit`; return (
- Hold {shortcut} to Quit + {message}
); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8516058169753..ca59fbc37d9f3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -34,6 +34,7 @@ import { MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, + type QuitConfirmationMode, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; @@ -163,6 +164,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const QUIT_CONFIRMATION_MODE_LABELS: Record = { + direct: "Direct", + hold: "Hold", + "double-click": "Double press", +}; + const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { balanced: "Balanced", performance: "Performance", @@ -542,9 +549,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), - ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit - ? ["Quit confirmation"] - : []), + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ...getChangedBrowserSettingLabels(settings), ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess @@ -2422,11 +2427,11 @@ export function GeneralSettingsPanel() { {isElectron ? ( updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) } @@ -2434,11 +2439,25 @@ export function GeneralSettingsPanel() { ) : null } control={ - updateSettings({ confirmQuit: Boolean(checked) })} - aria-label="Hold to quit" - /> + } /> ) : null} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0432994962a67..a3e5cf4b9a698 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -238,9 +238,9 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "quit-confirmation", - title: "Hold to quit", + title: "Quit shortcut", to: "/settings/general", - searchTerms: ["confirmation shortcut desktop app exit"], + searchTerms: ["confirmation desktop app exit direct hold double click press twice"], desktopOnly: true, }, { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7de366524713a..2d2cf29ec8141 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -91,7 +91,7 @@ import { EnvironmentId } from "./baseSchemas.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; -import type { ClientSettings } from "./settings.ts"; +import type { ClientSettings, QuitConfirmationMode } from "./settings.ts"; import type { EditorId } from "./editor.ts"; import type { SourceControlCloneRepositoryInput, @@ -116,6 +116,10 @@ export interface ContextMenuItem { children?: readonly ContextMenuItem[]; } +export type QuitShortcutHintEvent = + | { readonly state: "down"; readonly mode: Exclude } + | { readonly state: "up" }; + export interface ContextMenuItemSchemaType { readonly id: string; readonly label: string; @@ -1140,11 +1144,10 @@ export interface DesktopBridge { probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; /** - * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, - * "up" when it is released before the hold completes. Optional: older - * desktop builds never emit it. + * Quit-confirmation hint pushes. Optional: older desktop builds never emit + * them. */ - onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; + onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index fa1c702b19002..6c71a0e42970b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -15,6 +15,7 @@ import { const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); +const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -66,6 +67,31 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings quit confirmation", () => { + it("defaults to hold", () => { + expect(decodeClientSettings({}).confirmQuit).toBe("hold"); + }); + + it.each(["direct", "hold", "double-click"] as const)("accepts the %s mode", (mode) => { + expect(decodeClientSettings({ confirmQuit: mode }).confirmQuit).toBe(mode); + expect(decodeClientSettingsPatch({ confirmQuit: mode }).confirmQuit).toBe(mode); + }); + + it.each([ + [true, "hold"], + [false, "direct"], + ] as const)("migrates the legacy %s value to %s", (legacyValue, mode) => { + const settings = decodeClientSettings({ confirmQuit: legacyValue }); + + expect(settings.confirmQuit).toBe(mode); + expect(encodeClientSettings(settings).confirmQuit).toBe(mode); + }); + + it("rejects legacy booleans at the patch boundary", () => { + expect(() => decodeClientSettingsPatch({ confirmQuit: true })).toThrow(); + }); +}); + describe("ClientSettings browser recording frame rate", () => { it("defaults to 30 fps", () => { expect(decodeClientSettings({}).browserRecordingFrameRate).toBe(30); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 25577b752c638..f9a0d2cf9a7e7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -124,6 +124,22 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); +export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; +export const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; + +const LegacyConfirmQuit = Schema.Boolean.pipe( + Schema.decodeTo( + QuitConfirmationMode, + SchemaTransformation.transform({ + decode: (confirmQuit): QuitConfirmationMode => (confirmQuit ? "hold" : "direct"), + encode: (mode) => mode === "hold", + }), + ), +); + +const QuitConfirmationModeSetting = Schema.Union([QuitConfirmationMode, LegacyConfirmQuit]); + /** * A user-chosen font family (a single name or a comma-separated list). Empty * means "use the app default"; clients compose their own fallback stacks. @@ -183,9 +199,11 @@ export const ClientSettingsSchema = Schema.Struct({ browserAutoShowFloatingPreview: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)), ), - // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the - // app quits; a quick tap only shows a hint. Browser clients ignore it. - confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Desktop-only. Boolean values from older settings files decode to their + // equivalent mode and encode back as the canonical string value. + confirmQuit: QuitConfirmationModeSetting.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)), + ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -938,7 +956,7 @@ export const ClientSettingsPatch = Schema.Struct({ browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), - confirmQuit: Schema.optionalKey(Schema.Boolean), + confirmQuit: Schema.optionalKey(QuitConfirmationMode), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), From cb007469161ff0db2bc2dc8123c4b30e186aae50 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:43:24 -0400 Subject: [PATCH 028/136] feat(web): open project settings from thread menus (#8925) --- apps/web/src/components/LegacySidebar.tsx | 14 ++++++ apps/web/src/components/Sidebar.tsx | 30 ++++++++++--- .../components/threadActionMenu.logic.test.ts | 13 +++++- .../src/components/threadActionMenu.logic.ts | 2 + apps/web/src/contextMenuFallback.ts | 9 ++++ apps/web/src/hooks/useThreadActionMenu.ts | 44 ++++++++++++++++++- 6 files changed, 105 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 189ffaa0fb9e5..6514f7b4d5918 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2165,11 +2165,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "project-settings", label: "Project settings" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], position, ); + if (clicked === "project-settings") { + if (isMobile) setOpenMobile(false); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey: project.projectKey }, + }); + return; + } + if (clicked === "new-thread-on-branch") { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -2252,9 +2262,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard, deleteThread, handleNewThread, + isMobile, markThreadUnread, memberProjectByScopedKey, + project.projectKey, project.workspaceRoot, + router, + setOpenMobile, startThreadRename, ], ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0b85065e134c7..18e1971293090 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1888,6 +1888,8 @@ export default function Sidebar() { () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); + const projectGroupsRef = useRef(projectGroups); + projectGroupsRef.current = projectGroups; const serverConfigs = useAtomValue(environmentServerConfigsAtom); // Threads on non-primary environments (T3 Connect, hosted) resolve their // provider entry from their own environment's config: default instance ids @@ -2043,11 +2045,8 @@ export default function Sidebar() { clearSelection(); }, [clearSelection, projectScopeKey]); - const handleProjectSettings = useCallback( - (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { - event.preventDefault(); - event.stopPropagation(); - dispatchProjectScopeMenu({ type: "project-settings-opened" }); + const openProjectSettings = useCallback( + (projectGroup: SidebarProjectSnapshot) => { if (isMobile) { setOpenMobile(false); } @@ -2058,6 +2057,15 @@ export default function Sidebar() { }, [isMobile, router, setOpenMobile], ); + const handleProjectSettings = useCallback( + (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { + event.preventDefault(); + event.stopPropagation(); + dispatchProjectScopeMenu({ type: "project-settings-opened" }); + openProjectSettings(projectGroup); + }, + [openProjectSettings], + ); // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells: no archived-snapshot @@ -3122,6 +3130,17 @@ export default function Sidebar() { return; } switch (clicked.value) { + case "project-settings": { + const projectGroup = projectGroupsRef.current.find((group) => + group.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === thread.environmentId && + projectRef.projectId === thread.projectId, + ), + ); + if (projectGroup) openProjectSettings(projectGroup); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -3281,6 +3300,7 @@ export default function Sidebar() { deleteThread, handleMultiSelectContextMenu, markThreadUnread, + openProjectSettings, projectCwdByKey, serverConfigs, startThreadRename, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 96931bc8f8757..1bdd04693759a 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -33,7 +33,18 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy", "archive", "delete"]); + ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]); + }); + + it("groups project settings with utility actions before archive", () => { + const items = buildThreadActionMenuItems(baseState); + const copyIndex = items.findIndex((item) => item.id === "copy"); + expect(items[copyIndex + 1]).toMatchObject({ + id: "project-settings", + label: "Project settings", + icon: "settings", + }); + expect(items[copyIndex + 2]?.id).toBe("archive"); }); it("includes branch items only for threads with a branch", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index df983ee86773f..5ba266f7709d5 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -8,6 +8,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" */ export type ThreadActionMenuId = | "new-thread-on-branch" + | "project-settings" | "pin" | "unpin" | "settle" @@ -119,6 +120,7 @@ export function buildThreadActionMenuItems( { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, ], }, + { id: "project-settings", label: "Project settings", icon: "settings" }, // Archive removes the thread from the sidebar while keeping its // conversation under Settings > Archived threads — distinct from Settle // (stays visible in the Settled shelf) and Delete (clears history for diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index ce8b8950a8b9c..bfdb98c2d2295 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -96,6 +96,15 @@ const ICON_PATHS: Record void; }) { const { threadRef, projectCwd, onStartRename } = input; + const router = useRouter(); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const logicalProjectKeyByPhysicalKey = useMemo( + () => + buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }), + [primaryEnvironmentId, projectGroupingSettings, projects], + ); const { settleThread, unsettleThread, @@ -168,6 +190,22 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "project-settings": { + const project = projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && + candidate.id === thread.projectId, + ); + if (!project) return; + const projectKey = + logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey }, + }); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -300,10 +338,14 @@ export function useThreadActionMenu(input: { copyThreadIdToClipboard, deleteThread, handleNewThread, + logicalProjectKeyByPhysicalKey, markThreadUnread, onStartRename, pinThread, projectCwd, + projectGroupingSettings, + projects, + router, settleThread, snoozeThread, threadRef, From a924fbe08e681ef56d8cfb8b99e1e803d52aa463 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 16:48:27 -0400 Subject: [PATCH 029/136] fix(chat): reuse one row for live activity (#9062) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../chat/MessagesTimeline.logic.test.ts | 91 +++++++++++-------- .../components/chat/MessagesTimeline.logic.ts | 19 ++-- .../components/chat/MessagesTimeline.test.tsx | 42 +++++++-- .../src/components/chat/MessagesTimeline.tsx | 13 ++- 4 files changed, 106 insertions(+), 59 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d9bfdae04d493..d729ffa829f91 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -826,7 +826,7 @@ describe("deriveMessagesTimelineRows", () => { "assistant-final-entry", "user-followup-entry", "working-indicator-row", - "thinking-indicator-row", + "live-activity-row", ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); @@ -879,11 +879,11 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.map((row) => row.id)).toEqual([ "working-indicator-row", "assistant-thought-entry", - "work-live:work-entry-1", + "live-activity-row", ]); }); - it("keeps adjacent active tool calls in one replacing row", () => { + it("keeps an actually running tool in the shared activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -948,6 +948,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "thinking")).toBe(false); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, + active: true, groupedEntries: [ { id: "running-command" }, { id: "completed-edit" }, @@ -1196,40 +1197,54 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("shows thinking after the latest tool call completes while the turn is running", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "latest-command-entry", - kind: "work", - createdAt: "2026-01-01T00:00:05Z", - entry: { - id: "latest-command", - createdAt: "2026-01-01T00:00:05Z", - turnId: "turn-1" as never, - label: "Ran rg", - command: "rg toolCall", - requestKind: "command", - tone: "tool" as const, - toolLifecycleStatus: "completed" as const, - }, + it("reuses one activity row for initial thinking and the latest tool", () => { + const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | null) => + deriveMessagesTimelineRows({ + timelineEntries: + toolLifecycleStatus === null + ? [] + : [ + { + id: "latest-command-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "latest-command", + createdAt: "2026-01-01T00:00:05Z", + turnId: "turn-1" as never, + label: toolLifecycleStatus === "inProgress" ? "Running rg" : "Ran rg", + command: "rg toolCall", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, }, - ], - latestTurn: { - turnId: "turn-1" as never, - state: "running", - startedAt: "2026-01-01T00:00:00Z", - completedAt: null, - }, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); - expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); - expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); - expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); + const initialRows = deriveRows(null); + const runningRows = deriveRows("inProgress"); + const completedRows = deriveRows("completed"); + const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row"); + const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row"); + const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row"); + + expect(initialActivityRow).toMatchObject({ kind: "thinking" }); + expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(completedActivityRow).toMatchObject({ kind: "work-live", active: false }); + expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1290,7 +1305,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); + expect(rows.map((row) => row.id)).toContain("live-activity-row"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -1587,8 +1602,8 @@ describe("computeStableMessagesTimelineRows", () => { initial, ); - const initialThinking = initial.byId.get("thinking-indicator-row"); - const updatedThinking = updated.byId.get("thinking-indicator-row"); + const initialThinking = initial.byId.get("live-activity-row"); + const updatedThinking = updated.byId.get("live-activity-row"); expect(initialThinking).toMatchObject({ kind: "thinking" }); expect(updatedThinking).toBe(initialThinking); expect(updated.result.at(-1)).toBe(updatedThinking); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c787446f738b7..ea1e2c7562be2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -188,6 +188,8 @@ export type TimelineLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; +const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + export type MessagesTimelineRow = | { kind: "work"; @@ -598,17 +600,16 @@ export function deriveMessagesTimelineRows(input: { const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => workEntryIsActiveTurnActivity(entry.entry), ); - const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && displayedToolEntry + activeWorkAnchor && latestVisibleToolEntry ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { kind: "work-live" as const, - id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, + id: LIVE_ACTIVITY_ROW_ID, createdAt: activeWorkAnchor.createdAt, - entry: displayedToolEntry.entry, + entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, @@ -626,11 +627,11 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt, }); }; - let hasLiveWorkRow = false; + let hasActivityRow = false; const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); - hasLiveWorkRow ||= activeWorkRow.active; + hasActivityRow = true; if (!activeWorkRow.expanded) return; for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { nextRows.push({ @@ -730,7 +731,7 @@ export function deriveMessagesTimelineRows(input: { expanded, active: true, }); - hasLiveWorkRow = true; + hasActivityRow = true; if (expanded) { for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { nextRows.push({ @@ -832,10 +833,10 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if (input.isWorking && !hasLiveWorkRow) { + if (input.isWorking && !hasActivityRow) { nextRows.push({ kind: "thinking", - id: "thinking-indicator-row", + id: LIVE_ACTIVITY_ROW_ID, createdAt: input.activeTurnStartedAt, }); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 024dfe69d2782..5835cc9fed757 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1308,7 +1308,30 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("keeps declined command copy visible while thinking continues", () => { + it("renders initial thinking as the shared live activity row", () => { + const turnId = TurnId.make("turn-live"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Thinking"); + expect(markup).toContain("lucide-brain"); + expect(markup).toContain('data-timeline-row-id="live-activity-row"'); + }); + + it("keeps the completed command in the shared activity row", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { runningTurnId={turnId} timelineEntries={[ { - id: "entry-declined", + id: "entry-completed", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { - id: "work-declined", + id: "work-completed", createdAt: MESSAGE_CREATED_AT, turnId, - toolCallId: "call-declined", + toolCallId: "call-completed", label: "Run lint", tone: "tool", itemType: "command_execution", command: "pnpm lint", - toolLifecycleStatus: "declined", + toolLifecycleStatus: "completed", }, }, ]} />, ); - expect(markup).toContain("Declined pnpm"); - expect(markup).toContain("Thinking"); - expect(markup).toContain("tool call failed"); + expect(markup).toContain("Ran pnpm"); + expect(markup).toContain("lucide-terminal"); + expect(markup).not.toContain("live-activity-focus"); + expect(markup).not.toContain("Running pnpm"); + expect(markup).not.toContain("Thinking"); + expect(markup).not.toContain('data-timeline-row-kind="thinking"'); }); it("renders review comment contexts as structured cards instead of raw tags", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index dbe8507aef80e..df944a98485e4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -62,6 +62,7 @@ import { import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; import { BotIcon, + BrainIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, @@ -985,14 +986,15 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time : "pb-0" : isExpandedToolGroupHeader ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" + : row.kind === "turn-fold" || row.kind === "working" ? "pb-1.5" : (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" || row.kind === "work-live" || - row.kind === "work-toggle" + row.kind === "work-toggle" || + row.kind === "thinking" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -1364,7 +1366,7 @@ function ThinkingTimelineRow() { // Reserve the activity row during setup so the handoff keeps the same height. return (
- {isPreparingWorktree ? null : } + {isPreparingWorktree ? null : }
); } @@ -2123,6 +2125,7 @@ function formatWorkingTimerNow(startIso: string): string { type WorkEntryIconName = | "bot" + | "brain" | "check" | "circle-alert" | "eye" @@ -2140,6 +2143,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN switch (name) { case "bot": return ; + case "brain": + return ; case "check": return ; case "circle-alert": @@ -2179,7 +2184,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { } if (tone === "thinking") { return { - iconName: "bot", + iconName: "brain", className: "text-foreground", }; } From 03542836834d008a097450cf24d0d9c6f965b859 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 1 Sep 2026 13:53:46 -0700 Subject: [PATCH 030/136] feat(models): discover Claude models from remote manifest (#9084) --- .../src/provider/ClaudeModelCatalog.test.ts | 137 +++++ .../ClaudeModelCatalog.testFixtures.ts | 83 +++ .../server/src/provider/ClaudeModelCatalog.ts | 242 +++++++++ .../src/provider/ClaudeModelManifest.ts | 82 +++ .../src/provider/Drivers/ClaudeDriver.ts | 44 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 337 +++++------- .../src/provider/Layers/ClaudeAdapter.ts | 94 ++-- .../src/provider/Layers/ClaudeProvider.ts | 500 +----------------- .../provider/Layers/ProviderRegistry.test.ts | 242 --------- .../server/src/provider/ModelManifest.test.ts | 303 ++++++++--- apps/server/src/provider/ModelManifest.ts | 157 +++++- apps/server/src/provider/model-manifest.json | 359 +++++++++++++ .../ClaudeTextGeneration.test.ts | 99 +++- .../textGeneration/ClaudeTextGeneration.ts | 47 +- .../components/chat/ModelPickerContent.tsx | 5 +- .../chat/composerProviderState.test.tsx | 7 +- .../chat/modelPickerModelHighlights.ts | 13 - .../src/components/chat/providerIconUtils.ts | 1 + apps/web/src/modelSelection.ts | 4 + apps/web/src/providerModels.test.ts | 77 +++ apps/web/src/providerModels.ts | 8 +- docs/internals/model-manifest.md | 40 ++ packages/contracts/src/model.ts | 28 +- packages/contracts/src/server.ts | 2 + packages/shared/src/model.test.ts | 14 +- packages/shared/src/model.ts | 8 + 26 files changed, 1774 insertions(+), 1159 deletions(-) create mode 100644 apps/server/src/provider/ClaudeModelCatalog.test.ts create mode 100644 apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts create mode 100644 apps/server/src/provider/ClaudeModelCatalog.ts create mode 100644 apps/server/src/provider/ClaudeModelManifest.ts delete mode 100644 apps/web/src/components/chat/modelPickerModelHighlights.ts create mode 100644 apps/web/src/providerModels.test.ts create mode 100644 docs/internals/model-manifest.md diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts new file mode 100644 index 0000000000000..b370c8e24d3db --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -0,0 +1,137 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; + +import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; +import type { ModelManifestData } from "./ModelManifest.ts"; +import { + formatClaudeVersionUpgradeMessage, + normalizeClaudeCatalogEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeModelCatalog, + resolveClaudeModelsForVersion, + resolveClaudeModelSlug, +} from "./ClaudeModelCatalog.ts"; + +/** + * Test policy: adding or changing a real Claude model in model-manifest.json + * must not add or update tests here. These synthetic fixtures cover resolver + * behavior once. Add a test only when Claude adapter semantics change, such + * as introducing a new compatibility rule or dispatch mapping type. + */ + +const manifest = (): ModelManifestData => ({ + version: 1, + currentModels: {}, + providers: { + claudeAgent: { + profiles: { + synthetic: { + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "extreme", label: "Extreme", isDefault: true }], + }, + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [{ id: "large", label: "Large", isDefault: true }], + }, + ], + }, + adapter: { + claudeCode: { + effortMap: { extreme: "high" }, + modelSuffixes: { contextWindow: { large: "[large]" } }, + }, + }, + }, + }, + models: [ + { + slug: "claude-synthetic-next", + name: "Claude Synthetic Next", + aliases: ["synthetic"], + status: "current", + profile: "synthetic", + adapter: { claudeCode: { minVersion: "3.2.0" } }, + }, + ], + }, + }, +}); + +describe("Claude model catalog", () => { + it("filters models at runtime-version boundaries and derives the upgrade message", () => { + const catalog = resolveClaudeModelCatalog(manifest()); + assert.deepStrictEqual(resolveClaudeModelsForVersion(catalog, "3.1.9"), []); + assert.deepStrictEqual( + resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), + ["claude-synthetic-next"], + ); + assert.strictEqual( + formatClaudeVersionUpgradeMessage(catalog, "3.1.9"), + "Claude Code v3.1.9 is too old for Claude Synthetic Next. Upgrade to v3.2.0 or newer to access it.", + ); + }); + + it("resolves aliases and declarative adapter mappings", () => { + const base = manifest(); + const input: ModelManifestData = { + ...base, + providers: { + ...base.providers, + claudeAgent: { + ...base.providers!.claudeAgent!, + models: [ + { + slug: "claude-synthetic-collision", + name: "Claude Synthetic Collision", + aliases: ["claude-synthetic-next"], + status: "current", + }, + ...base.providers!.claudeAgent!.models, + ], + }, + }, + }; + const catalog = resolveClaudeModelCatalog(input); + assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "claude-synthetic-next"); + assert.strictEqual( + resolveClaudeModelSlug(catalog, "claude-synthetic-next"), + "claude-synthetic-next", + ); + assert.strictEqual(normalizeClaudeCatalogEffort(catalog, "extreme", "synthetic"), "high"); + assert.strictEqual( + resolveClaudeCatalogApiModelId(catalog, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "synthetic", + }), + "claude-synthetic-next[large]", + ); + }); + + it("rejects malformed adapter mappings", () => { + const base = manifest(); + const malformed: ModelManifestData = { + ...base, + providers: { + ...base.providers, + claudeAgent: { + ...base.providers!.claudeAgent!, + profiles: { + ...base.providers!.claudeAgent!.profiles, + synthetic: { + ...base.providers!.claudeAgent!.profiles.synthetic!, + adapter: { claudeCode: { effortMap: { extreme: 123 } } }, + }, + }, + }, + }, + }; + assert.isFalse(hasValidClaudeManifestAdapters(malformed)); + }); +}); diff --git a/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts b/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts new file mode 100644 index 0000000000000..8fd9f5d769857 --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts @@ -0,0 +1,83 @@ +import type { ClaudeModelCatalog } from "./ClaudeModelCatalog.ts"; + +// Transport tests must stay independent of bundled or remote manifest contents. +// Keep every model, alias, capability, and runtime mapping in this fixture synthetic. +export const SYNTHETIC_CLAUDE_CAPABLE_MODEL = "claude-synthetic-capable"; +export const SYNTHETIC_CLAUDE_COLLIDING_ALIAS = "synthetic-collision"; +export const SYNTHETIC_CLAUDE_STANDARD_MODEL = "claude-synthetic-standard"; +export const SYNTHETIC_CLAUDE_THINKING_MODEL = "claude-synthetic-thinking"; + +const effort = { + id: "effort", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Max" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], +}; + +const contextWindow = { + id: "contextWindow", + label: "Context Window", + type: "select" as const, + options: [ + { id: "standard", label: "Standard" }, + { id: "expanded", label: "Expanded", isDefault: true }, + ], +}; + +const runtime = { + effortMap: { ultrathink: null }, + modelSuffixes: { contextWindow: { expanded: "[expanded]" } }, + contextWindowTokens: { standard: 200_000, expanded: 1_000_000 }, +}; + +export const SYNTHETIC_CLAUDE_MODEL_CATALOG: ClaudeModelCatalog = { + models: [ + { + model: { + slug: SYNTHETIC_CLAUDE_CAPABLE_MODEL, + name: "Claude Synthetic Capable", + aliases: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS], + isCustom: false, + capabilities: { + optionDescriptors: [ + effort, + { id: "fastMode", label: "Fast Mode", type: "boolean" }, + contextWindow, + ], + }, + }, + runtime, + compatibility: {}, + }, + { + model: { + slug: SYNTHETIC_CLAUDE_STANDARD_MODEL, + name: "Claude Synthetic Standard", + isCustom: false, + capabilities: { + optionDescriptors: [effort, contextWindow], + }, + }, + runtime, + compatibility: {}, + }, + { + model: { + slug: SYNTHETIC_CLAUDE_THINKING_MODEL, + name: "Claude Synthetic Thinking", + isCustom: false, + capabilities: { + optionDescriptors: [{ id: "thinking", label: "Thinking", type: "boolean" }], + }, + }, + runtime: {}, + compatibility: {}, + }, + ], +}; diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts new file mode 100644 index 0000000000000..bd554f042f0bf --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -0,0 +1,242 @@ +import { + type ModelCapabilities, + type ModelSelection, + ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, + normalizeCustomModelSlug, +} from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; + +import { + type ClaudeCodeCompatibility, + type ClaudeCodeProfile, + decodeClaudeModelAdapter, + decodeClaudeProfileAdapter, +} from "./ClaudeModelManifest.ts"; +import { + BUNDLED_MODEL_MANIFEST, + type ModelManifestData, + resolveProviderCatalog, +} from "./ModelManifest.ts"; + +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] }; + +export interface ClaudeCatalogModel { + readonly model: ServerProviderModel; + readonly runtime: ClaudeCodeProfile; + readonly compatibility: ClaudeCodeCompatibility; +} + +export interface ClaudeModelCatalog { + readonly models: ReadonlyArray; +} + +function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog | null { + const resolved = resolveProviderCatalog(manifest, CLAUDE); + if (!resolved) return null; + + const models: Array = []; + for (const entry of resolved.models) { + const profile = decodeClaudeProfileAdapter(entry.profileAdapter ?? {}); + const adapter = decodeClaudeModelAdapter(entry.adapter ?? {}); + if (Option.isNone(profile) || Option.isNone(adapter)) return null; + models.push({ + model: entry.model, + runtime: profile.value.claudeCode ?? {}, + compatibility: adapter.value.claudeCode ?? {}, + }); + } + + return { + models, + }; +} + +export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog { + return ( + tryResolveClaudeModelCatalog(manifest) ?? + tryResolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST) ?? { + models: [], + } + ); +} + +export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); + +/** Keeps custom model aliases opaque while preserving canonical built-in models and capabilities. */ +export function scopeClaudeModelCatalog( + catalog: ClaudeModelCatalog, + customModels: ReadonlyArray, +): ClaudeModelCatalog { + const customAliases = new Set( + customModels.flatMap((model) => { + const slug = normalizeCustomModelSlug(model); + return slug ? [slug.toLowerCase()] : []; + }), + ); + if (customAliases.size === 0) return catalog; + + return { + models: catalog.models.map((entry) => { + if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { + return entry; + } + return { + ...entry, + model: { + ...entry.model, + aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), + }, + }; + }), + }; +} + +export function resolveClaudeCatalogModel( + catalog: ClaudeModelCatalog, + slugOrAlias: string | null | undefined, +): ClaudeCatalogModel | undefined { + const value = slugOrAlias?.trim(); + if (!value) return undefined; + return ( + catalog.models.find((entry) => entry.model.slug === value) ?? + catalog.models.find((entry) => + entry.model.aliases?.some((alias) => alias.toLowerCase() === value.toLowerCase()), + ) + ); +} + +export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { + return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; +} + +export function getClaudeCatalogModelCapabilities( + catalog: ClaudeModelCatalog, + slugOrAlias: string | null | undefined, +): ModelCapabilities { + return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.capabilities ?? EMPTY_CAPABILITIES; +} + +function isVersionSupported( + compatibility: ClaudeCodeCompatibility, + version: string | null | undefined, +): boolean { + if (!compatibility.minVersion && !compatibility.maxVersionExclusive) return true; + if (!version) return false; + if (compatibility.minVersion && compareSemverVersions(version, compatibility.minVersion) < 0) { + return false; + } + return !( + compatibility.maxVersionExclusive && + compareSemverVersions(version, compatibility.maxVersionExclusive) >= 0 + ); +} + +export function resolveClaudeModelsForVersion( + catalog: ClaudeModelCatalog, + version: string | null | undefined, +): ReadonlyArray { + return catalog.models + .filter((entry) => isVersionSupported(entry.compatibility, version)) + .map((entry) => entry.model); +} + +export function formatClaudeVersionUpgradeMessage( + catalog: ClaudeModelCatalog, + version: string | null, +): string | undefined { + const unavailable = catalog.models + .filter( + (entry) => + entry.compatibility.minVersion && + (!version || compareSemverVersions(version, entry.compatibility.minVersion) < 0), + ) + .toSorted((left, right) => + compareSemverVersions(left.compatibility.minVersion!, right.compatibility.minVersion!), + )[0]; + if (!unavailable?.compatibility.minVersion) return undefined; + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for ${unavailable.model.name}. Upgrade to v${unavailable.compatibility.minVersion} or newer to access it.`; +} + +export function resolveClaudeCatalogEffort( + catalog: ClaudeModelCatalog, + model: string | null | undefined, + raw: string | null | undefined, +): string | undefined { + const caps = getClaudeCatalogModelCapabilities(catalog, model); + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), + }); + const descriptor = descriptors.find((candidate) => candidate.id === "effort"); + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : undefined; +} + +export function normalizeClaudeCatalogEffort( + catalog: ClaudeModelCatalog, + effort: string | null | undefined, + model: string | null | undefined, +): string | undefined { + if (!effort) return undefined; + const effortMap = resolveClaudeCatalogModel(catalog, model)?.runtime.effortMap; + if (!effortMap || !Object.prototype.hasOwnProperty.call(effortMap, effort)) return effort; + return effortMap[effort] ?? undefined; +} + +export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined): boolean { + return effort === "ultracode"; +} + +export function resolveClaudeCatalogContextWindow( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection | undefined, +): string | undefined { + const caps = getClaudeCatalogModelCapabilities(catalog, modelSelection?.model); + const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}), + }); + const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow"); + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : undefined; +} + +export function resolveClaudeCatalogApiModelId( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection, +): string { + const entry = resolveClaudeCatalogModel(catalog, modelSelection.model); + const slug = entry?.model.slug ?? modelSelection.model; + const descriptors = getProviderOptionDescriptors({ + caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES, + selections: modelSelection.options, + }); + for (const [optionId, suffixes] of Object.entries(entry?.runtime.modelSuffixes ?? {})) { + const value = getProviderOptionCurrentValue( + descriptors.find((descriptor) => descriptor.id === optionId), + ); + if (typeof value === "string" && suffixes[value]) return `${slug}${suffixes[value]}`; + } + return slug; +} + +export function resolveClaudeCatalogContextWindowTokens( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection | undefined, +): number | undefined { + const entry = resolveClaudeCatalogModel(catalog, modelSelection?.model); + if (!entry) return undefined; + if (entry.runtime.fixedContextWindowTokens) return entry.runtime.fixedContextWindowTokens; + const contextWindow = resolveClaudeCatalogContextWindow(catalog, modelSelection); + return contextWindow ? entry.runtime.contextWindowTokens?.[contextWindow] : undefined; +} diff --git a/apps/server/src/provider/ClaudeModelManifest.ts b/apps/server/src/provider/ClaudeModelManifest.ts new file mode 100644 index 0000000000000..1bac30b2ce30e --- /dev/null +++ b/apps/server/src/provider/ClaudeModelManifest.ts @@ -0,0 +1,82 @@ +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export const ClaudeCodeProfileSchema = Schema.Struct({ + effortMap: Schema.optional( + Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)), + ), + modelSuffixes: Schema.optional( + Schema.Record( + TrimmedNonEmptyString, + Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), + ), + ), + contextWindowTokens: Schema.optional(Schema.Record(TrimmedNonEmptyString, Schema.Number)), + fixedContextWindowTokens: Schema.optional(Schema.Number), +}); + +export const ClaudeProfileAdapterSchema = Schema.Struct({ + claudeCode: Schema.optional(ClaudeCodeProfileSchema), +}); + +const ClaudeVersionSchema = TrimmedNonEmptyString.pipe( + Schema.check( + Schema.makeFilter((version) => parseSemver(version) !== null, { + expected: "a supported semantic version", + }), + ), +); + +const ClaudeCodeCompatibilitySchema = Schema.Struct({ + minVersion: Schema.optional(ClaudeVersionSchema), + maxVersionExclusive: Schema.optional(ClaudeVersionSchema), +}).pipe( + Schema.check( + Schema.makeFilter( + ({ minVersion, maxVersionExclusive }) => + minVersion === undefined || + maxVersionExclusive === undefined || + compareSemverVersions(minVersion, maxVersionExclusive) < 0, + { expected: "a minimum version below the exclusive maximum version" }, + ), + ), +); + +export const ClaudeModelAdapterSchema = Schema.Struct({ + claudeCode: Schema.optional(ClaudeCodeCompatibilitySchema), +}); + +export type ClaudeCodeProfile = typeof ClaudeCodeProfileSchema.Type; +export type ClaudeCodeCompatibility = NonNullable; + +export const decodeClaudeProfileAdapter = Schema.decodeUnknownOption(ClaudeProfileAdapterSchema); +export const decodeClaudeModelAdapter = Schema.decodeUnknownOption(ClaudeModelAdapterSchema); + +interface ClaudeManifestAdapterInput { + readonly providers?: + | Readonly< + Record< + string, + | { + readonly profiles: Readonly>; + readonly models: ReadonlyArray<{ readonly adapter?: unknown }>; + } + | undefined + > + > + | undefined; +} + +export function hasValidClaudeManifestAdapters(manifest: ClaudeManifestAdapterInput): boolean { + const catalog = manifest.providers?.claudeAgent; + if (!catalog) return true; + + return ( + Object.values(catalog.profiles).every((profile) => + Option.isSome(decodeClaudeProfileAdapter(profile.adapter ?? {})), + ) && + catalog.models.every((model) => Option.isSome(decodeClaudeModelAdapter(model.adapter ?? {}))) + ); +} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 0409b7c691b1c..571862c2a29eb 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -35,6 +35,7 @@ import { probeClaudeCapabilities, } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { @@ -128,6 +129,7 @@ export const ClaudeDriver: ProviderDriver = { const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const modelManifest = yield* ModelManifest.ModelManifest; + const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog)); const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -149,10 +151,15 @@ export const ClaudeDriver: ProviderDriver = { const adapterOptions = { instanceId, environment: processEnv, + modelCatalog, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }; const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); - const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv); + const textGeneration = yield* makeClaudeTextGeneration( + effectiveConfig, + processEnv, + modelCatalog, + ); // Per-instance capabilities cache: keyed on binary + resolved HOME so // account-specific probes never share auth metadata across instances. @@ -166,22 +173,21 @@ export const ClaudeDriver: ProviderDriver = { }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); - // Kick the TTL-gated manifest refresh in the background and classify - // with the in-memory manifest, so a slow or hung fetch never delays the - // provider check. A refresh that lands mid-probe applies on the next one. + // Start the TTL-gated refresh without delaying provider readiness. The + // next check observes a remote manifest after the background fetch lands. const checkProvider = modelManifest.refreshInBackground.pipe( Effect.andThen( - Effect.zipWith( - checkClaudeProviderStatus( - effectiveConfig, - () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), - processEnv, - cwd, + modelManifest.current.pipe( + Effect.flatMap((manifest) => + checkClaudeProviderStatus( + effectiveConfig, + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + processEnv, + cwd, + resolveClaudeModelCatalog(manifest), + ), ), - modelManifest.current, - (draft, manifest) => - stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), - { concurrent: true }, + Effect.map(stampIdentity), ), ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -196,11 +202,11 @@ export const ClaudeDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - Effect.zipWith( - makePendingClaudeProvider(settings.provider), - modelManifest.current, - (draft, manifest) => - stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + modelManifest.current.pipe( + Effect.flatMap((manifest) => + makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)), + ), + Effect.map(stampIdentity), ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 9e56176a032f7..1eacce8854716 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -35,6 +35,13 @@ import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + SYNTHETIC_CLAUDE_MODEL_CATALOG, + SYNTHETIC_CLAUDE_STANDARD_MODEL, + SYNTHETIC_CLAUDE_THINKING_MODEL, +} from "../ClaudeModelCatalog.testFixtures.ts"; import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; @@ -166,6 +173,7 @@ function makeHarness(config?: { const adapterOptions: ClaudeAdapterLiveOptions = { ...(config?.instanceId ? { instanceId: config.instanceId } : {}), + modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), createQuery: (input) => { createInput = input; return query; @@ -265,6 +273,7 @@ async function readFirstPromptMessage( const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); +const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; describe("ClaudeAdapterLive", () => { it.effect("returns validation error for non-claude provider on startSession", () => { @@ -441,7 +450,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "effort", value: "max" }], ), runtimeMode: "full-access", @@ -464,7 +473,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, ), runtimeMode: "full-access", }); @@ -480,167 +489,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-7", - }, - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "max"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-7", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "max"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("preserves xhigh effort for Claude Fable 5.1", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-fable-5-1", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("preserves xhigh effort for Claude Fable 5", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-fable-5", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("preserves xhigh effort for Claude Opus 5", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-5", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", - [{ id: "effort", value: "max" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "high"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("ignores adaptive effort for Haiku 4.5", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-haiku-4-5", - [{ id: "effort", value: "high" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, undefined); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("forwards Claude thinking toggle into SDK settings for Haiku 4.5", () => { + it.effect("forwards Claude thinking toggle for models that support it", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -649,7 +498,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-haiku-4-5", + SYNTHETIC_CLAUDE_THINKING_MODEL, [{ id: "thinking", value: false }], ), runtimeMode: "full-access", @@ -665,7 +514,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("ignores Claude thinking toggle for non-Haiku models", () => { + it.effect("ignores Claude thinking toggle for models without it", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -674,7 +523,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "thinking", value: false }], ), runtimeMode: "full-access", @@ -697,7 +546,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "fastMode", value: true }], ), runtimeMode: "full-access", @@ -713,7 +562,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("ignores claude fast mode for non-opus models", () => { + it.effect("ignores claude fast mode for models without it", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -722,7 +571,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "fastMode", value: true }], ), runtimeMode: "full-access", @@ -736,6 +585,97 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect( + "keeps a configured custom alias opaque without disabling the canonical built-in", + () => { + const claudeConfig = { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }; + const customHarness = makeHarness({ claudeConfig }); + const builtInHarness = makeHarness({ claudeConfig }); + const start = (harness: ReturnType, model: string) => + Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection(ProviderInstanceId.make("claudeAgent"), model, [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "expanded" }, + ]), + runtimeMode: "full-access", + }); + return harness.getLastCreateQueryInput()!.options; + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + const runCustomFlow = Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "expanded" }, + ], + ), + runtimeMode: "full-access", + }); + const options = customHarness.getLastCreateQueryInput()!.options; + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "use the built-in model", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "contextWindow", value: "expanded" }], + ), + attachments: [], + }); + yield* Effect.promise(() => readFirstPromptText(customHarness.getLastCreateQueryInput())); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "keep this prompt literal", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + [{ id: "effort", value: "ultrathink" }], + ), + attachments: [], + }); + const prompt = yield* Effect.promise(() => + readFirstPromptText(customHarness.getLastCreateQueryInput()), + ); + return { options, prompt }; + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(customHarness.layer), + ); + + return Effect.gen(function* () { + const { options: customOptions, prompt: customPrompt } = yield* runCustomFlow; + assert.equal(customOptions.model, SYNTHETIC_CLAUDE_COLLIDING_ALIAS); + assert.equal(customOptions.effort, undefined); + assert.equal(customOptions.settings, undefined); + assert.deepEqual(customHarness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + ]); + assert.equal(customPrompt, "keep this prompt literal"); + + const builtInOptions = yield* start(builtInHarness, SYNTHETIC_CLAUDE_CAPABLE_MODEL); + assert.equal(builtInOptions.model, `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`); + assert.equal(builtInOptions.effort, "max"); + assert.deepEqual(builtInOptions.settings, { fastMode: true }); + }); + }, + ); + it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -745,7 +685,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "effort", value: "ultrathink" }], ), runtimeMode: "full-access", @@ -757,7 +697,7 @@ describe("ClaudeAdapterLive", () => { attachments: [], modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "effort", value: "ultrathink" }], ), }); @@ -778,7 +718,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; const modelSelection = createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "effort", value: "ultrathink" }], ); const session = yield* adapter.startSession({ @@ -885,7 +825,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-5", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, runtimeMode: "full-access", }); @@ -1900,7 +1840,7 @@ describe("ClaudeAdapterLive", () => { output_tokens: 50, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -1990,7 +1930,7 @@ describe("ClaudeAdapterLive", () => { output_tokens: 50, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -2125,7 +2065,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "effort", value: "max" }], ), runtimeMode: "full-access", @@ -2154,7 +2094,7 @@ describe("ClaudeAdapterLive", () => { type: "assistant", parent_tool_use_id: "toolu_agent_m", message: { - model: "claude-sonnet-5[1m]", + model: SYNTHETIC_SUBAGENT_MODEL, content: [], }, uuid: "subagent-snapshot-uuid", @@ -2174,13 +2114,13 @@ describe("ClaudeAdapterLive", () => { const started = taskEvents[0]; assert.equal(started?.type, "task.started"); if (started?.type === "task.started") { - assert.equal(started.payload.model, "claude-opus-4-6"); + assert.equal(started.payload.model, SYNTHETIC_CLAUDE_CAPABLE_MODEL); assert.equal(started.payload.effort, "max"); } const progress = taskEvents[1]; assert.equal(progress?.type, "task.progress"); if (progress?.type === "task.progress") { - assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL); assert.equal(progress.payload.effort, "max"); } }).pipe( @@ -2206,7 +2146,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "effort", value: "max" }], ), runtimeMode: "full-access", @@ -2223,7 +2163,7 @@ describe("ClaudeAdapterLive", () => { type: "assistant", parent_tool_use_id: "toolu_agent_early", message: { - model: "claude-sonnet-5[1m]", + model: SYNTHETIC_SUBAGENT_MODEL, content: [], }, uuid: "early-snapshot-uuid", @@ -2253,13 +2193,13 @@ describe("ClaudeAdapterLive", () => { const started = taskEvents[0]; assert.equal(started?.type, "task.started"); if (started?.type === "task.started") { - assert.equal(started.payload.model, "claude-sonnet-5[1m]"); + assert.equal(started.payload.model, SYNTHETIC_SUBAGENT_MODEL); assert.equal(started.payload.effort, "max"); } const progress = taskEvents[1]; assert.equal(progress?.type, "task.progress"); if (progress?.type === "task.progress") { - assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -2884,7 +2824,7 @@ describe("ClaudeAdapterLive", () => { output_tokens: 679, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -2948,7 +2888,7 @@ describe("ClaudeAdapterLive", () => { total_tokens: 535000, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -3025,7 +2965,7 @@ describe("ClaudeAdapterLive", () => { total_tokens: 535000, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -4069,7 +4009,7 @@ describe("ClaudeAdapterLive", () => { cwd: "/tmp/claude-adapter-test", tools: [], mcp_servers: [], - model: "claude-sonnet-4-5", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, permissionMode: "bypassPermissions", slash_commands: [], output_style: "default", @@ -4232,12 +4172,14 @@ describe("ClaudeAdapterLive", () => { input: "hello", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-6", + model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, }, attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); + assert.deepEqual(harness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + ]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -4280,7 +4222,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; const modelSelection = { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-6", + model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, }; const session = yield* adapter.startSession({ @@ -4327,8 +4269,8 @@ describe("ClaudeAdapterLive", () => { input: "hello", modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "contextWindow", value: "1m" }], + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "contextWindow", value: "expanded" }], ), attachments: [], }); @@ -4337,13 +4279,16 @@ describe("ClaudeAdapterLive", () => { input: "hello again", modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "contextWindow", value: "200k" }], + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "contextWindow", value: "standard" }], ), attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]", "claude-opus-4-6"]); + assert.deepEqual(harness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + ]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -4553,7 +4498,7 @@ describe("ClaudeAdapterLive", () => { uuid: "assistant-exit-plan", parent_tool_use_id: null, message: { - model: "claude-opus-4-6", + model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, id: "msg-exit-plan", type: "message", role: "assistant", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6989378d82877..ed1a27a73bdf6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -80,13 +80,17 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { - getClaudeModelCapabilities, - isClaudeUltracodeEffort, - normalizeClaudeCliEffort, - resolveClaudeApiModelId, - resolveClaudeContextWindow, - resolveClaudeEffort, -} from "./ClaudeProvider.ts"; + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + isClaudeCatalogUltracodeEffort, + normalizeClaudeCatalogEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeCatalogContextWindowTokens, + resolveClaudeCatalogEffort, + resolveClaudeModelSlug, + scopeClaudeModelCatalog, +} from "../ClaudeModelCatalog.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -333,6 +337,7 @@ export interface ClaudeAdapterLiveOptions { }) => ClaudeQueryRuntime; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + readonly modelCatalog?: Effect.Effect; } function isUuid(value: string): boolean { @@ -381,10 +386,11 @@ function normalizeClaudeStreamMessages( } function getEffectiveClaudeAgentEffort( + catalog: ClaudeModelCatalog, effort: string | null | undefined, model: string | null | undefined, ): ClaudeSdkEffort | null { - const normalized = normalizeClaudeCliEffort(effort, model); + const normalized = normalizeClaudeCatalogEffort(catalog, effort, model); return normalized ? (normalized as ClaudeSdkEffort) : null; } @@ -470,23 +476,10 @@ function maxClaudeContextWindowFromModelUsage( } function selectedClaudeContextWindow( + catalog: ClaudeModelCatalog, modelSelection: ModelSelection | undefined, ): number | undefined { - switch (modelSelection?.model) { - case "claude-opus-4-8": - case "claude-opus-4-7": - // Always 1M at the API; these models expose no contextWindow option. - return 1_000_000; - } - - switch (resolveClaudeContextWindow(modelSelection)) { - case "1m": - return 1_000_000; - case "200k": - return 200_000; - default: - return undefined; - } + return resolveClaudeCatalogContextWindowTokens(catalog, modelSelection); } function finiteNonNegativeInteger(value: unknown): number | undefined { @@ -1234,6 +1227,7 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText( input: ProviderSendTurnInput, boundInstanceId: ProviderInstanceId, + catalog: ClaudeModelCatalog, ): string { const rawEffort = input.modelSelection?.instanceId === boundInstanceId @@ -1241,7 +1235,7 @@ function buildPromptText( : null; const claudeModel = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined; - const caps = getClaudeModelCapabilities(claudeModel); + const caps = getClaudeCatalogModelCapabilities(catalog, claudeModel); const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); @@ -1281,9 +1275,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( readonly fileSystem: FileSystem.FileSystem; readonly attachmentsDir: string; readonly boundInstanceId: ProviderInstanceId; + readonly modelCatalog: ClaudeModelCatalog; }, ) { - const text = buildPromptText(input, dependencies.boundInstanceId); + const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog); const sdkContent: Array> = []; if (text.length > 0) { @@ -1682,6 +1677,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( options?: ClaudeAdapterLiveOptions, ) { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent"); + const modelCatalogEffect = ( + options?.modelCatalog ?? Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG) + ).pipe(Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels))); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; @@ -3805,6 +3803,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const startSession: ClaudeAdapterShape["startSession"] = Effect.fn("startSession")( function* (input) { + const modelCatalog = yield* modelCatalogEffect; if (input.provider !== undefined && input.provider !== PROVIDER) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -4243,14 +4242,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; - const modelSelection = + const selectedModel = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const caps = getClaudeModelCapabilities(modelSelection?.model); + const modelSelection = selectedModel + ? { + ...selectedModel, + model: resolveClaudeModelSlug(modelCatalog, selectedModel.model), + } + : undefined; + const caps = getClaudeCatalogModelCapabilities(modelCatalog, modelSelection?.model); const descriptors = getProviderOptionDescriptors({ caps }); - const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; - const initialContextWindow = selectedClaudeContextWindow(modelSelection); + const apiModelId = modelSelection + ? resolveClaudeCatalogApiModelId(modelCatalog, modelSelection) + : undefined; + const initialContextWindow = selectedClaudeContextWindow(modelCatalog, modelSelection); const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); - const effort = resolveClaudeEffort(caps, rawEffort) ?? null; + const effort = + resolveClaudeCatalogEffort(modelCatalog, modelSelection?.model, rawEffort) ?? null; const fastModeSupported = descriptors.some( (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", ); @@ -4263,8 +4271,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const thinking = thinkingSupported ? getModelSelectionBooleanOptionValue(modelSelection, "thinking") : undefined; - const ultracode = isClaudeUltracodeEffort(effort); - const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model); + const ultracode = isClaudeCatalogUltracodeEffort(effort); + const effectiveEffort = getEffectiveClaudeAgentEffort( + modelCatalog, + effort, + modelSelection?.model, + ); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", auto: "auto", @@ -4495,10 +4507,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = yield* requireSession(input.threadId); - const modelSelection = + const modelCatalog = yield* modelCatalogEffect; + const selectedModel = input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId ? input.modelSelection : undefined; + const modelSelection = selectedModel + ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) } + : undefined; // A sendTurn while a real turn is running is a steer: the message is // queued into the live SDK agent loop and the work continues as the same @@ -4512,7 +4528,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (modelSelection?.model) { - const apiModelId = resolveClaudeApiModelId(modelSelection); + const apiModelId = resolveClaudeCatalogApiModelId(modelCatalog, modelSelection); if (context.currentApiModelId !== apiModelId) { yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), @@ -4524,13 +4540,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...context.session, model: modelSelection.model, }; - const turnCaps = getClaudeModelCapabilities(modelSelection.model); - const turnEffort = resolveClaudeEffort( - turnCaps, + const turnEffort = resolveClaudeCatalogEffort( + modelCatalog, + modelSelection.model, getModelSelectionStringOptionValue(modelSelection, "effort"), ); context.currentEffort = - getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined; + getEffectiveClaudeAgentEffort(modelCatalog, turnEffort ?? null, modelSelection.model) ?? + undefined; } // Apply interaction mode by switching the SDK's permission mode. @@ -4589,6 +4606,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( fileSystem, attachmentsDir: serverConfig.attachmentsDir, boundInstanceId, + modelCatalog, }); yield* Queue.offer(context.promptQueue, { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b3665977ef3fa..bf41046f61e86 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,8 +1,6 @@ import { type ClaudeSettings, type ModelCapabilities, - type ModelSelection, - type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -12,14 +10,8 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - createModelCapabilities, - getModelSelectionStringOptionValue, - getProviderOptionCurrentValue, - getProviderOptionDescriptors, -} from "@t3tools/shared/model"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, type Options as ClaudeQueryOptions, @@ -29,8 +21,6 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import { - buildBooleanOptionDescriptor, - buildSelectOptionDescriptor, buildServerProvider, DEFAULT_TIMEOUT_MS, isCommandMissingCause, @@ -42,6 +32,12 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + formatClaudeVersionUpgradeMessage, + resolveClaudeModelsForVersion, +} from "../ClaudeModelCatalog.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -51,468 +47,6 @@ const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, } as const; -const MINIMUM_CLAUDE_FABLE_5_1_VERSION = "2.1.257"; -const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; -const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; -const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; -const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; - -const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ - { - slug: "claude-fable-5-1", - name: "Claude Fable 5.1", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-fable-5", - name: "Claude Fable 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-opus-5", - name: "Claude Opus 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - // Claude Code selects the 1M variant explicitly (`claude-opus-5[1m]`). - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-opus-4-8", - name: "Claude Opus 4.8", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], - }), - }, - { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], - }), - }, - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-opus-4-5", - name: "Claude Opus 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - ], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], - }), - }, - { - slug: "claude-sonnet-5", - name: "Claude Sonnet 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - // Sonnet is 200k-default in Claude Code (1M is opt-in there too). - options: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }), - ], - }), - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - // Sonnet is 200k-default in Claude Code (1M is opt-in there too). - options: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }), - ], - }), - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildBooleanOptionDescriptor({ - id: "thinking", - label: "Thinking", - }), - ], - }), - }, -]; - -// Legacy classification happens at the driver boundary via `applyModelManifest`, -// so the catalog itself carries no `isLegacy` flags. -const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG; - -function supportsClaudeFable51(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_1_VERSION) >= 0 : false; -} - -function supportsClaudeOpus5(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; -} - -function supportsClaudeFable5(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; -} - -function supportsClaudeOpus48(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_8_VERSION) >= 0 : false; -} - -function supportsClaudeOpus47(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; -} - -function getBuiltInClaudeModelsForVersion( - version: string | null | undefined, -): ReadonlyArray { - return BUILT_IN_MODELS.filter((model) => { - if (model.slug === "claude-fable-5-1") { - return supportsClaudeFable51(version); - } - if (model.slug === "claude-opus-5") { - return supportsClaudeOpus5(version); - } - if (model.slug === "claude-fable-5") { - return supportsClaudeFable5(version); - } - if (model.slug === "claude-opus-4-8") { - return supportsClaudeOpus48(version); - } - if (model.slug === "claude-opus-4-7") { - return supportsClaudeOpus47(version); - } - return true; - }); -} - -function formatClaudeFable51UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Fable 5.1. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_1_VERSION} or newer to access it.`; -} - -function formatClaudeOpus5UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; -} - -function formatClaudeFable5UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`; -} - -function formatClaudeOpus48UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 4.8. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_8_VERSION} or newer to access it.`; -} - -function formatClaudeOpus47UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`; -} - -export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { - const slug = model?.trim(); - return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? - DEFAULT_CLAUDE_MODEL_CAPABILITIES - ); -} - -export function resolveClaudeEffort( - caps: ModelCapabilities, - raw: string | null | undefined, -): string | undefined { - const descriptors = getProviderOptionDescriptors({ - caps, - ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), - }); - const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort"); - const value = getProviderOptionCurrentValue(effortDescriptor); - return typeof value === "string" ? value : undefined; -} - -/** - * Normalize a resolved Claude effort value into one suitable for the Claude - * CLI's `--effort` flag. - * - * Mirrors the mapping used when invoking the Claude Agent SDK - * ({@link getEffectiveClaudeAgentEffort} in ClaudeAdapter): `ultracode` is a - * Claude Code setting that pairs with `xhigh`, `ultrathink` is filtered out - * because it is a prompt-prefix mode, and older model compatibility mappings - * are preserved for current Claude Code behavior. - */ -export function normalizeClaudeCliEffort( - effort: string | null | undefined, - model: string | null | undefined, -): string | undefined { - if (!effort || effort === "ultrathink") { - return undefined; - } - if (effort === "ultracode") { - return "xhigh"; - } - if ( - effort === "xhigh" && - model !== "claude-fable-5-1" && - model !== "claude-fable-5" && - model !== "claude-opus-5" && - model !== "claude-opus-4-8" && - model !== "claude-sonnet-5" - ) { - return "max"; - } - if (effort === "max" && model === "claude-sonnet-4-6") { - return "high"; - } - return effort; -} - -export function isClaudeUltracodeEffort(effort: string | null | undefined): boolean { - return effort === "ultracode"; -} - -export function resolveClaudeContextWindow( - modelSelection: ModelSelection | undefined, -): string | undefined { - const caps = getClaudeModelCapabilities(modelSelection?.model); - const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); - const descriptors = getProviderOptionDescriptors({ - caps, - ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}), - }); - const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow"); - const value = getProviderOptionCurrentValue(descriptor); - return typeof value === "string" ? value : undefined; -} - -export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { - switch (resolveClaudeContextWindow(modelSelection)) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - return modelSelection.model; - } -} - function toTitleCaseWords(value: string): string { const parts: Array = []; for (const part of value.split(/[\s_-]+/g)) { @@ -860,6 +394,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ) => Effect.Effect, environment?: NodeJS.ProcessEnv, cwd?: string, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, ): Effect.fn.Return< ServerProviderDraft, never, @@ -868,7 +403,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( - BUILT_IN_MODELS, + modelCatalog.models.map((entry) => entry.model), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -958,21 +493,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( } const models = providerModelsFromSettings( - getBuiltInClaudeModelsForVersion(parsedVersion), + resolveClaudeModelsForVersion(modelCatalog, parsedVersion), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeFable51(parsedVersion) - ? undefined - : supportsClaudeOpus5(parsedVersion) - ? formatClaudeFable51UpgradeMessage(parsedVersion) - : supportsClaudeFable5(parsedVersion) - ? formatClaudeOpus5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) @@ -1035,11 +560,12 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso); export const makePendingClaudeProvider = ( claudeSettings: ClaudeSettings, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, ): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( - BUILT_IN_MODELS, + modelCatalog.models.map((entry) => entry.model), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f483442c5a74f..e1624a424dbad 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2051,248 +2051,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); - it.effect("includes Claude Opus 5 on supported Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const opus5 = status.models.find((model) => model.slug === "claude-opus-5"); - assert.strictEqual(opus5?.name, "Claude Opus 5"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Opus 5 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-5"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.218 is too old for Claude Opus 5. Upgrade to v2.1.219 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("includes Claude Fable 5 on supported Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); - assert.strictEqual(fable5?.name, "Claude Fable 5"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.169\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("includes Claude Fable 5.1 on supported Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const fable51 = status.models.find((model) => model.slug === "claude-fable-5-1"); - assert.strictEqual(fable51?.name, "Claude Fable 5.1"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.257\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Fable 5.1 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-fable-5-1"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.256 is too old for Claude Fable 5.1. Upgrade to v2.1.257 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.256\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Fable 5 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-fable-5"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.168 is too old for Claude Fable 5. Upgrade to v2.1.169 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.168\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect( - "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", - () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); - if (!opus47) { - assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); - } - if (!opus47.capabilities) { - assert.fail( - "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", - ); - } - const effortDescriptor = opus47.capabilities.optionDescriptors?.find( - (descriptor) => descriptor.type === "select" && descriptor.id === "effort", - ); - assert.deepStrictEqual( - effortDescriptor?.type === "select" - ? effortDescriptor.options.find((option) => option.isDefault) - : undefined, - { id: "xhigh", label: "Extra High", isDefault: true }, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-7"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 102940ea23743..e46a462e438a7 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; @@ -10,61 +11,20 @@ import * as ServerSettings from "../serverSettings.ts"; import { BUNDLED_MODEL_MANIFEST, classifyModels, - isLegacyModel, make, + resolveProviderCatalog, type ModelManifestData, } from "./ModelManifest.ts"; -const CODEX = ProviderDriverKind.make("codex"); -const CLAUDE = ProviderDriverKind.make("claudeAgent"); -const CURSOR = ProviderDriverKind.make("cursor"); - -describe("isLegacyModel (bundled manifest)", () => { - it("keeps current Codex models out of legacy models", () => { - assert.deepStrictEqual( - [ - "gpt-5.6-luna", - "gpt-5.6-terra", - "gpt-5.6-sol", - "gpt-daybreak-blue-latest", - "gpt-daybreak-red-latest", - "gpt-5.4", - ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CODEX, model)]), - [ - ["gpt-5.6-luna", false], - ["gpt-5.6-terra", false], - ["gpt-5.6-sol", false], - ["gpt-daybreak-blue-latest", false], - ["gpt-daybreak-red-latest", false], - ["gpt-5.4", true], - ], - ); - }); - - it("keeps only the Claude 5 family out of legacy models", () => { - assert.deepStrictEqual( - [ - "claude-fable-5-1", - "claude-fable-5", - "claude-opus-5", - "claude-sonnet-5", - "claude-opus-4-8", - ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model)]), - [ - ["claude-fable-5-1", false], - ["claude-fable-5", true], - ["claude-opus-5", false], - ["claude-sonnet-5", false], - ["claude-opus-4-8", true], - ], - ); - }); - - it("leaves driver kinds without a manifest entry unflagged", () => { - assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5")); - }); -}); +/** + * Test policy: this file covers manifest machinery, not manifest contents. + * Do not add assertions for real model slugs, names, status, aliases, or + * profiles when editing model-manifest.json. Add tests only when fetch/cache + * behavior or the provider-neutral resolver semantics change, and use + * synthetic models for resolver coverage. + */ +const CODEX = ProviderDriverKind.make("codex"); const model = (overrides: Partial): ServerProviderModel => ({ slug: "gpt-test", name: "GPT Test", @@ -75,37 +35,227 @@ const model = (overrides: Partial): ServerProviderModel => describe("classifyModels", () => { it("flags non-current models, clears stale flags, and skips custom models", () => { + const manifest: ModelManifestData = { + version: 1, + currentModels: { codex: ["current-a", "current-b"] }, + }; const models = [ - model({ slug: "gpt-5.6-sol" }), + model({ slug: "current-a" }), // Stale flag from a previous classification pass must be cleared. - model({ slug: "gpt-5.6-luna", isLegacy: true }), - model({ slug: "gpt-5.4" }), + model({ slug: "current-b", isLegacy: true }), + model({ slug: "old-model" }), // Custom models are user-defined and never reclassified. model({ slug: "my-own-model", isCustom: true }), ]; assert.deepStrictEqual( - classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [ - entry.slug, - entry.isLegacy ?? false, - ]), + classifyModels(models, manifest, CODEX).map((entry) => [entry.slug, entry.isLegacy ?? false]), [ - ["gpt-5.6-sol", false], - ["gpt-5.6-luna", false], - ["gpt-5.4", true], + ["current-a", false], + ["current-b", false], + ["old-model", true], ["my-own-model", false], ], ); }); }); +describe("resolveProviderCatalog", () => { + it("resolves generic model presentation through a reusable profile", () => { + const manifest: ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + synthetic: { + defaults: { chat: "model-next" }, + profiles: { + standard: { + capabilities: { + optionDescriptors: [ + { + id: "mode", + label: "Mode", + type: "select", + options: [{ id: "fast", label: "Fast", isDefault: true }], + }, + ], + }, + adapter: { opaque: true }, + }, + }, + models: [ + { + slug: "model-next", + name: "Model Next", + aliases: ["next"], + status: "current", + badge: "new", + profile: "standard", + }, + ], + }, + }, + }; + + const catalog = resolveProviderCatalog(manifest, ProviderDriverKind.make("synthetic")); + assert.deepStrictEqual(catalog?.models[0], { + model: { + slug: "model-next", + name: "Model Next", + aliases: ["next"], + badge: "new", + isCustom: false, + isDefault: true, + capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!, + }, + adapter: undefined, + profileAdapter: { opaque: true }, + }); + }); + + it("rejects invalid catalog references", () => { + const invalidCatalog = (input: { + readonly models: NonNullable[string]["models"]; + readonly defaultChat?: string; + }): ModelManifestData => ({ + version: 1, + currentModels: {}, + providers: { + synthetic: { + ...(input.defaultChat ? { defaults: { chat: input.defaultChat } } : {}), + profiles: {}, + models: input.models, + }, + }, + }); + + for (const invalid of [ + invalidCatalog({ + models: [ + { slug: "duplicate", name: "First", status: "current" }, + { slug: "duplicate", name: "Second", status: "current" }, + ], + }), + invalidCatalog({ + models: [ + { + slug: "missing-profile", + name: "Missing Profile", + status: "current", + profile: "missing", + }, + ], + }), + invalidCatalog({ + models: [{ slug: "present", name: "Present", status: "current" }], + defaultChat: "absent", + }), + ]) { + assert.isNull(resolveProviderCatalog(invalid, ProviderDriverKind.make("synthetic"))); + } + }); +}); + const REMOTE_MANIFEST: ModelManifestData = { version: 1, currentModels: { - codex: ["gpt-5.4"], - claudeAgent: ["claude-fable-5"], + codex: ["remote-model"], + claudeAgent: ["remote-agent-model"], }, }; +const REMOTE_CLAUDE_MANIFEST: ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + claudeAgent: { + profiles: { + synthetic: { + adapter: { claudeCode: { effortMap: { extreme: "high" } } }, + }, + }, + models: [ + { + slug: "remote-only-model", + name: "Remote Only Model", + status: "current", + profile: "synthetic", + }, + ], + }, + }, +}; + +const remoteClaudeManifestWithCompatibility = (compatibility: unknown): ModelManifestData => ({ + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models.map((model) => ({ + ...model, + adapter: { claudeCode: compatibility }, + })), + }, + }, +}); + +const INVALID_REMOTE_MANIFESTS: ReadonlyArray = [ + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: { + synthetic: { + adapter: { claudeCode: { effortMap: { extreme: 123 } } }, + }, + }, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: {}, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: [ + ...REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + { + slug: "remote-only-model", + name: "Duplicate Remote Model", + status: "current", + profile: "synthetic", + }, + ], + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + defaults: { chat: "absent-model" }, + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + remoteClaudeManifestWithCompatibility({ minVersion: "2.x" }), + remoteClaudeManifestWithCompatibility({ maxVersionExclusive: "2.x" }), + remoteClaudeManifestWithCompatibility({ + minVersion: "2.2", + maxVersionExclusive: "2.1", + }), +]; + const httpClientLayer = (handler: () => Response) => Layer.succeed( HttpClient.HttpClient, @@ -129,8 +279,6 @@ describe("ModelManifest service", () => { const service = yield* make; const refreshed = yield* service.refresh; assert.deepStrictEqual(refreshed, REMOTE_MANIFEST); - assert.isTrue(isLegacyModel(refreshed, CODEX, "gpt-5.6-sol")); - assert.isFalse(isLegacyModel(refreshed, CODEX, "gpt-5.4")); // A fresh service instance sees the disk cache without another fetch: // its HTTP layer is still stubbed, but `current` never fetches at all. @@ -162,6 +310,33 @@ describe("ModelManifest service", () => { ), ); + it.effect("preserves the last-good remote cache when later payloads are invalid", () => { + let responseIndex = 0; + const responses = [REMOTE_CLAUDE_MANIFEST, ...INVALID_REMOTE_MANIFESTS]; + + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_CLAUDE_MANIFEST); + + for (const _invalid of INVALID_REMOTE_MANIFESTS) { + yield* TestClock.adjust("1 hour"); + responseIndex += 1; + assert.deepStrictEqual(yield* service.refresh, REMOTE_CLAUDE_MANIFEST); + } + + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_CLAUDE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-last-good-test", + response: () => Response.json(responses[responseIndex]), + }), + ), + ); + }); + it.live("does not fetch when provider update checks are disabled", () => Effect.gen(function* () { let fetchCount = 0; diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index cb94949922875..2f378f835d671 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -1,20 +1,24 @@ /** - * ModelManifest — decides which provider models are current and which belong - * in the model picker's legacy section. + * ModelManifest — remote provider-model metadata with a bundled offline + * fallback. * - * The classification data (current slugs per driver kind) lives in - * `model-manifest.json` next to this file. The bundled copy ships with every - * release. At runtime the service refreshes it from the same file on `main` - * via raw.githubusercontent.com, so a new model can leave the legacy section - * with a commit to `main` instead of a release. Preference order is remote, - * then the on-disk copy of the last successful fetch, then the bundle. A - * failed fetch never fails a provider check. + * Provider catalogs and legacy classification live in `model-manifest.json`. + * The bundled copy ships with every release; at runtime the service refreshes + * it from the same file on `main`. Preference order is remote, then the last + * successful on-disk copy, then the bundle. A failed fetch never fails a + * provider check. * - * Drivers apply the manifest to snapshot drafts with `applyModelManifest` - * before publishing, so every path that produces models (pending, probe, - * error fallbacks) is classified the same way. + * Providers with authoritative discovery can use only the classification + * overlay. Providers with static catalogs can resolve presentation and + * capabilities from `providers`, then decode their own allowlisted adapter + * payload separately. */ -import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; +import { + ModelCapabilities, + TrimmedNonEmptyString, + type ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -27,6 +31,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import bundledManifestJson from "./model-manifest.json" with { type: "json" }; import type { ServerProviderDraft } from "./providerSnapshot.ts"; @@ -42,23 +47,135 @@ const MANIFEST_RETRY_MS = 5 * 60 * 1000; const FETCH_TIMEOUT_MS = 10_000; +const ManifestModelStatus = Schema.Literals(["current", "legacy"]); + +const ManifestModelProfile = Schema.Struct({ + capabilities: Schema.optional(ModelCapabilities), + adapter: Schema.optional(Schema.Unknown), +}); + +const ManifestProviderModel = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + shortName: Schema.optional(TrimmedNonEmptyString), + subProvider: Schema.optional(TrimmedNonEmptyString), + aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + status: ManifestModelStatus, + badge: Schema.optional(Schema.Literal("new")), + profile: Schema.optional(TrimmedNonEmptyString), + adapter: Schema.optional(Schema.Unknown), +}); + +const ManifestProviderCatalog = Schema.Struct({ + defaults: Schema.optional( + Schema.Struct({ + chat: Schema.optional(TrimmedNonEmptyString), + }), + ), + profiles: Schema.Record(Schema.String, ManifestModelProfile), + models: Schema.Array(ManifestProviderModel), +}); + /** - * `version` gates breaking schema changes: a build only accepts remote - * manifests whose version it understands, and keeps its bundled copy - * otherwise. `currentModels` is keyed by driver kind; kinds absent from the - * map have no legacy concept and their models are left unflagged. + * `version` gates breaking schema changes. Provider catalogs are additive so + * clients that only understand `currentModels` keep accepting this v1 file. */ -const ModelManifestSchema = Schema.Struct({ +const ModelManifestEnvelopeSchema = Schema.Struct({ version: Schema.Literal(1), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), + providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), }); + +const hasValidProviderCatalogReferences = ( + manifest: typeof ModelManifestEnvelopeSchema.Type, +): boolean => + Object.values(manifest.providers ?? {}).every((catalog) => { + const slugs = new Set(); + const modelsAreValid = catalog.models.every((model) => { + if (slugs.has(model.slug)) return false; + slugs.add(model.slug); + return model.profile === undefined || catalog.profiles[model.profile] !== undefined; + }); + return ( + modelsAreValid && (catalog.defaults?.chat === undefined || slugs.has(catalog.defaults.chat)) + ); + }); + +const ModelManifestSchema = ModelManifestEnvelopeSchema.pipe( + Schema.check( + Schema.makeFilter(hasValidProviderCatalogReferences, { + expected: "unique model slugs and existing model and profile references", + }), + Schema.makeFilter(hasValidClaudeManifestAdapters, { + expected: "valid Claude adapter metadata", + }), + ), +); export type ModelManifestData = typeof ModelManifestSchema.Type; +export interface ResolvedManifestModel { + readonly model: ServerProviderModel; + readonly adapter: unknown; + readonly profileAdapter: unknown; +} + +export interface ResolvedProviderCatalog { + readonly models: ReadonlyArray; + readonly defaults: { + readonly chat: string | undefined; + }; +} + const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); export const BUNDLED_MODEL_MANIFEST: ModelManifestData = Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); +/** Resolve provider-neutral model presentation and capability data. */ +export function resolveProviderCatalog( + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ResolvedProviderCatalog | null { + const catalog = manifest.providers?.[driverKind]; + if (!catalog) return null; + + const seen = new Set(); + const models: Array = []; + for (const entry of catalog.models) { + if (seen.has(entry.slug)) return null; + seen.add(entry.slug); + + const profile = entry.profile ? catalog.profiles[entry.profile] : undefined; + if (entry.profile && !profile) return null; + + models.push({ + model: { + slug: entry.slug, + name: entry.name, + ...(entry.shortName ? { shortName: entry.shortName } : {}), + ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), + ...(entry.aliases ? { aliases: entry.aliases } : {}), + ...(entry.badge ? { badge: entry.badge } : {}), + isCustom: false, + ...(catalog.defaults?.chat === entry.slug ? { isDefault: true } : {}), + ...(entry.status === "legacy" ? { isLegacy: true } : {}), + capabilities: profile?.capabilities ?? null, + }, + adapter: entry.adapter, + profileAdapter: profile?.adapter, + }); + } + + if (catalog.defaults?.chat !== undefined && !seen.has(catalog.defaults.chat)) return null; + + return { + models, + defaults: { + chat: catalog.defaults?.chat, + }, + }; +} + /** On-disk shape of the last successfully fetched manifest. */ const ManifestCacheFile = Schema.Struct({ fetchedAtMs: Schema.Number, @@ -81,6 +198,10 @@ export function isLegacyModel( driverKind: ProviderDriverKind, slug: string, ): boolean { + const catalogModel = manifest.providers?.[driverKind]?.models.find( + (model) => model.slug === slug, + ); + if (catalogModel) return catalogModel.status === "legacy"; const currentModels = manifest.currentModels[driverKind]; if (!currentModels) return false; return !currentModels.includes(slug); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 337c32e1fda1c..713015965057e 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -9,5 +9,364 @@ "gpt-daybreak-red-latest" ], "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"] + }, + "providers": { + "claudeAgent": { + "defaults": { + "chat": "claude-sonnet-5" + }, + "profiles": { + "fable-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "opus-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "opus-4-8": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-7": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High" }, + { "id": "xhigh", "label": "Extra High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "xhigh": "max", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "opus-4-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" } + ] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { "claudeCode": {} } + }, + "sonnet-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k", "isDefault": true }, + { "id": "1m", "label": "1M" } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "sonnet-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k", "isDefault": true }, + { "id": "1m", "label": "1M" } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "max": "high", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "haiku-4-5": { + "capabilities": { + "optionDescriptors": [{ "id": "thinking", "label": "Thinking", "type": "boolean" }] + }, + "adapter": { "claudeCode": {} } + } + }, + "models": [ + { + "slug": "claude-fable-5-1", + "name": "Claude Fable 5.1", + "aliases": ["fable", "fable-5.1", "claude-fable-5.1"], + "status": "current", + "badge": "new", + "profile": "fable-5", + "adapter": { "claudeCode": { "minVersion": "2.1.257" } } + }, + { + "slug": "claude-fable-5", + "name": "Claude Fable 5", + "status": "legacy", + "profile": "fable-5", + "adapter": { "claudeCode": { "minVersion": "2.1.169" } } + }, + { + "slug": "claude-opus-5", + "name": "Claude Opus 5", + "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"], + "status": "current", + "profile": "opus-5", + "adapter": { "claudeCode": { "minVersion": "2.1.219" } } + }, + { + "slug": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "aliases": ["opus-4.8", "claude-opus-4.8"], + "status": "legacy", + "profile": "opus-4-8", + "adapter": { "claudeCode": { "minVersion": "2.1.154" } } + }, + { + "slug": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "aliases": ["opus-4.7", "claude-opus-4.7"], + "status": "legacy", + "profile": "opus-4-7", + "adapter": { "claudeCode": { "minVersion": "2.1.111" } } + }, + { + "slug": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "aliases": ["opus-4.6", "claude-opus-4.6", "claude-opus-4-6-20251117"], + "status": "legacy", + "profile": "opus-4-6" + }, + { + "slug": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "status": "legacy", + "profile": "opus-4-5" + }, + { + "slug": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "aliases": ["sonnet", "sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"], + "status": "current", + "profile": "sonnet-5" + }, + { + "slug": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "aliases": ["sonnet-4.6", "claude-sonnet-4.6", "claude-sonnet-4-6-20251117"], + "status": "legacy", + "profile": "sonnet-4-6" + }, + { + "slug": "claude-haiku-4-5", + "name": "Claude Haiku 4.5", + "aliases": ["haiku", "haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"], + "status": "legacy", + "profile": "haiku-4-5" + } + ] + } } } diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index d1bd68cb19d47..8dcaa3720295b 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -11,6 +11,13 @@ import * as Schema from "effect/Schema"; import { expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; +import { + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + SYNTHETIC_CLAUDE_MODEL_CATALOG, + SYNTHETIC_CLAUDE_STANDARD_MODEL, + SYNTHETIC_CLAUDE_THINKING_MODEL, +} from "../provider/ClaudeModelCatalog.testFixtures.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; @@ -219,13 +226,17 @@ function withFakeClaudeEnv( ); const config = decodeClaudeSettings(input.claudeConfig ?? {}); - const textGeneration = yield* makeClaudeTextGeneration(config); + const textGeneration = yield* makeClaudeTextGeneration( + config, + undefined, + Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), + ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { - it.effect("forwards Claude thinking settings for Haiku without passing effort", () => + it.effect("forwards Claude thinking settings without passing unsupported effort", () => withFakeClaudeEnv( { output: JSON.stringify({ @@ -245,10 +256,14 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", modelSelection: { - ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ - { id: "thinking", value: false }, - { id: "effort", value: "high" }, - ]), + ...createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_THINKING_MODEL, + [ + { id: "thinking", value: false }, + { id: "effort", value: "high" }, + ], + ), }, }); @@ -257,39 +272,83 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ), ); - it.effect("forwards Claude fast mode and supported effort", () => + it.effect("keeps a configured custom alias opaque to the Claude CLI", () => withFakeClaudeEnv( { output: JSON.stringify({ structured_output: { - title: "Improve orchestration flow", - body: "Body", + title: "Keep custom model", + body: "", }, }), - argsMustContain: '--effort max --settings {"fastMode":true}', + argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --dangerously-skip-permissions`, + claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }, }, (textGeneration) => Effect.gen(function* () { const generated = yield* textGeneration.generatePrContent({ cwd: process.cwd(), baseBranch: "main", - headBranch: "feature/claude-effect", - commitSummary: "Improve orchestration", + headBranch: "feature/custom-model", + commitSummary: "Keep custom model", diffSummary: "1 file changed", diffPatch: "diff --git a/README.md b/README.md", - modelSelection: { - ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + [ { id: "effort", value: "max" }, { id: "fastMode", value: true }, - ]), - }, + { id: "contextWindow", value: "expanded" }, + ], + ), }); - expect(generated.title).toBe("Improve orchestration flow"); + expect(generated.title).toBe("Keep custom model"); }), ), ); + it.effect( + "keeps canonical built-in capabilities when a custom model collides with its alias", + () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Improve orchestration flow", + body: "Body", + }, + }), + argsMustContain: `--model ${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded] --effort max --settings {"fastMode":true} --dangerously-skip-permissions`, + claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/claude-effect", + commitSummary: "Improve orchestration", + diffSummary: "1 file changed", + diffPatch: "diff --git a/README.md b/README.md", + modelSelection: { + ...createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + ), + }, + }); + + expect(generated.title).toBe("Improve orchestration flow"); + }), + ), + ); + it.effect("generates thread titles through the Claude provider", () => withFakeClaudeEnv( { @@ -308,7 +367,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { message: "Please investigate reconnect failures after restarting the session.", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, }); @@ -343,7 +402,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { message: "thread title", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, }); @@ -369,7 +428,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { message: "Name this thread.", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index aa3e59e2bf218..e5b13b70f1b73 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -37,12 +37,16 @@ import { getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { - getClaudeModelCapabilities, - isClaudeUltracodeEffort, - normalizeClaudeCliEffort, - resolveClaudeApiModelId, - resolveClaudeEffort, -} from "../provider/Layers/ClaudeProvider.ts"; + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + isClaudeCatalogUltracodeEffort, + normalizeClaudeCatalogEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeCatalogEffort, + resolveClaudeModelSlug, + scopeClaudeModelCatalog, +} from "../provider/ClaudeModelCatalog.ts"; import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -61,9 +65,13 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, + modelCatalog: Effect.Effect = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const scopedModelCatalog = modelCatalog.pipe( + Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)), + ); const readStreamAsString = ( operation: string, @@ -121,21 +129,34 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu outputSchemaJson: S; modelSelection: ModelSelection; }): Effect.fn.Return { + const catalog = yield* scopedModelCatalog; + const resolvedModelSelection = { + ...modelSelection, + model: resolveClaudeModelSlug(catalog, modelSelection.model), + }; const jsonSchemaStr = yield* encodeJsonForOperation( operation, toJsonSchemaObject(outputSchemaJson), "Failed to encode structured output schema.", ); - const caps = getClaudeModelCapabilities(modelSelection.model); + const caps = getClaudeCatalogModelCapabilities(catalog, resolvedModelSelection.model); const descriptors = getProviderOptionDescriptors({ caps, - selections: modelSelection.options, + selections: resolvedModelSelection.options, }); const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); - const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); - const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); - const cliEffort = normalizeClaudeCliEffort(resolvedEffort, modelSelection.model); - const ultracode = isClaudeUltracodeEffort(resolvedEffort); + const rawEffortSelection = getModelSelectionStringOptionValue(resolvedModelSelection, "effort"); + const resolvedEffort = resolveClaudeCatalogEffort( + catalog, + resolvedModelSelection.model, + rawEffortSelection, + ); + const cliEffort = normalizeClaudeCatalogEffort( + catalog, + resolvedEffort, + resolvedModelSelection.model, + ); + const ultracode = isClaudeCatalogUltracodeEffort(resolvedEffort); const thinkingDescriptor = findDescriptor("thinking"); const fastModeDescriptor = findDescriptor("fastMode"); const thinking = @@ -166,7 +187,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "--json-schema", jsonSchemaStr, "--model", - resolveClaudeApiModelId(modelSelection), + resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection), ...(cliEffort ? ["--effort", cliEffort] : []), ...(settingsJson ? ["--settings", settingsJson] : []), "--dangerously-skip-permissions", diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index a8ebac4f4e6a7..8d94fde1c0e83 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -15,7 +15,6 @@ import { parseModelPickerLegacySectionKey, parseModelPickerModelKey, } from "./modelPickerKeys"; -import { isModelPickerNewModel } from "./modelPickerModelHighlights"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; import { Combobox, @@ -47,6 +46,7 @@ type ModelPickerItem = { name: string; shortName?: string; subProvider?: string; + badge?: "new"; instanceId: ProviderInstanceId; driverKind: ProviderDriverKind; instanceDisplayName: string; @@ -256,6 +256,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { name: model.name, ...(model.shortName ? { shortName: model.shortName } : {}), ...(model.subProvider ? { subProvider: model.subProvider } : {}), + ...(model.badge ? { badge: model.badge } : {}), ...(model.isLegacy ? { isLegacy: true } : {}), ...(model.isUnavailable ? { isUnavailable: true } : {}), instanceId, @@ -804,7 +805,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { showProvider preferShortName={!isLocked} useTriggerLabel={false} - showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} + showNewBadge={model.badge === "new"} unavailable={model.isUnavailable === true} jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} disabledReason={disabledReason} diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 63fd7c5cd21ce..b419489bef837 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -291,11 +291,12 @@ describe("getComposerProviderState", () => { it("validates options for a known model selected through a legacy alias", () => { const state = getComposerProviderState({ provider: ProviderDriverKind.make("claudeAgent"), - model: "opus", + model: "legacy-test-model", models: [ { - slug: "claude-opus-5", - name: "Claude Opus 5", + slug: "test-model", + name: "Test Model", + aliases: ["legacy-test-model"], isCustom: false, capabilities: { optionDescriptors: [ diff --git a/apps/web/src/components/chat/modelPickerModelHighlights.ts b/apps/web/src/components/chat/modelPickerModelHighlights.ts deleted file mode 100644 index 2131480a6a23c..0000000000000 --- a/apps/web/src/components/chat/modelPickerModelHighlights.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ProviderDriverKind } from "@t3tools/contracts"; - -/** - * Model slugs that show a gold "NEW" chip in the model picker list. - * Add entries as `provider:slug` when you want to highlight freshly shipped models. - */ -const NEW_MODEL_KEYS = new Set([ - // Example: "claudeAgent:claude-opus-4-7", -]); - -export function isModelPickerNewModel(provider: ProviderDriverKind, slug: string): boolean { - return NEW_MODEL_KEYS.has(`${provider}:${slug}`); -} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f0c5b9b466d63..b21c10420294d 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -26,6 +26,7 @@ export type ModelEsque = { name: string; shortName?: string | undefined; subProvider?: string | undefined; + badge?: "new" | undefined; isLegacy?: boolean | undefined; isUnavailable?: boolean | undefined; }; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index a3d93f3c24bb3..12865d64a9b2a 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -75,6 +75,8 @@ export interface AppModelOption { name: string; shortName?: string; subProvider?: string; + aliases?: ReadonlyArray; + badge?: "new"; isCustom: boolean; isDefault?: boolean; isLegacy?: boolean; @@ -109,6 +111,8 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti }; if (model.shortName) option.shortName = model.shortName; if (model.subProvider) option.subProvider = model.subProvider; + if (model.aliases) option.aliases = model.aliases; + if (model.badge) option.badge = model.badge; if (model.isDefault) option.isDefault = true; if (model.isLegacy) option.isLegacy = true; return option; diff --git a/apps/web/src/providerModels.test.ts b/apps/web/src/providerModels.test.ts new file mode 100644 index 0000000000000..d28cc559062e5 --- /dev/null +++ b/apps/web/src/providerModels.test.ts @@ -0,0 +1,77 @@ +import { + ProviderDriverKind, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getProviderModelCapabilities } from "./providerModels"; + +const PROVIDER = ProviderDriverKind.make("claudeAgent"); + +function capabilities(id: string): ModelCapabilities { + return { + optionDescriptors: [{ id, label: id, type: "boolean" }], + }; +} + +function model(input: { + slug: string; + capabilities: ModelCapabilities; + aliases?: ReadonlyArray; + isCustom?: boolean; +}): ServerProviderModel { + return { + slug: input.slug, + name: input.slug, + ...(input.aliases ? { aliases: [...input.aliases] } : {}), + isCustom: input.isCustom ?? false, + capabilities: input.capabilities, + }; +} + +describe("getProviderModelCapabilities", () => { + it("resolves model-declared aliases", () => { + const aliasCapabilities = capabilities("aliased-option"); + const models = [ + model({ + slug: "synthetic-model", + aliases: ["Legacy-Synthetic-Model"], + capabilities: aliasCapabilities, + }), + ]; + + expect(getProviderModelCapabilities(models, "legacy-synthetic-model", PROVIDER)).toEqual( + aliasCapabilities, + ); + }); + + it("prefers an exact custom slug over a built-in model alias", () => { + const customCapabilities = capabilities("custom-option"); + const models = [ + model({ + slug: "synthetic-model", + aliases: ["custom-model"], + capabilities: capabilities("built-in-option"), + }), + model({ slug: "custom-model", capabilities: customCapabilities, isCustom: true }), + ]; + + expect(getProviderModelCapabilities(models, " custom-model ", PROVIDER)).toEqual( + customCapabilities, + ); + }); + + it("returns empty capabilities for an unknown slug", () => { + const models = [ + model({ + slug: "default-model", + capabilities: capabilities("default-option"), + }), + ]; + + expect(getProviderModelCapabilities(models, "unknown-model", PROVIDER)).toEqual({ + optionDescriptors: [], + }); + }); +}); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 6fc8b5e122a95..2f640d582307a 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -8,7 +8,7 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model"; +import { createModelCapabilities, resolveSelectableModel } from "@t3tools/shared/model"; const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -83,9 +83,9 @@ export function getProviderModelCapabilities( provider: ProviderDriverKind, planModeEnabled = true, ): ModelCapabilities { - const slug = normalizeModelSlug(model, provider); - const caps = - models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; + const slug = resolveSelectableModel(provider, model, models); + const selectedModel = models.find((candidate) => candidate.slug === slug); + const caps = selectedModel?.capabilities ?? EMPTY_CAPABILITIES; if (planModeEnabled) { return caps; } diff --git a/docs/internals/model-manifest.md b/docs/internals/model-manifest.md new file mode 100644 index 0000000000000..bebf5c2ee5270 --- /dev/null +++ b/docs/internals/model-manifest.md @@ -0,0 +1,40 @@ +# Model manifest + +`apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from +`main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic +catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the +last successful remote manifest. The bundle is used only when no valid remote cache exists. + +The top-level provider catalog is generic: models contain presentation metadata, aliases, status, +an optional badge, and a reusable capability profile. The profile and model `adapter` fields are +opaque until the owning provider validates them with its own allowlisted schema. + +Claude Code uses the manifest as its complete built-in model catalog. To add a Claude model that +uses an existing profile, add one object to `providers.claudeAgent.models`. Do not add a test or +change application code. Add or change a profile in the same JSON file only when the model exposes +a capability combination that does not already exist. + +`currentModels.claudeAgent` is retained as a frozen compatibility field for releases that predate +catalog discovery. New Claude models do not need to be added there. Codex still discovers models +from its app server and uses `currentModels.codex` only as a legacy-classification overlay. + +Claude model entries support: + +- `aliases`, `status`, `badge`, and `profile` for client presentation and selection. +- `adapter.claudeCode.minVersion` and `maxVersionExclusive` for installed-runtime compatibility. +- Profile-level effort mappings, model suffixes, and context-window metadata for dispatch. + +## Test policy + +Changing model data does not require tests. Do not add or update tests for a model slug, display +name, alias, legacy status, version boundary, badge, or profile assignment. The bundled manifest is +configuration and is validated by its schema when imported. + +Add tests only when implementation behavior changes: + +- Fetching, caching, fallback, or schema-version handling changes in the manifest service. +- Provider-neutral profile resolution gains new semantics. +- A provider adapter gains a new compatibility or dispatch mapping type. + +Resolver tests must use synthetic providers and model names so normal JSON edits never create test +churn. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index b4ab84259e680..92080d0cb4ab0 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -176,33 +176,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, - [CLAUDE_DRIVER_KIND]: { - fable: "claude-fable-5-1", - "fable-5.1": "claude-fable-5-1", - "claude-fable-5.1": "claude-fable-5-1", - opus: "claude-opus-5", - "opus-5": "claude-opus-5", - "claude-opus-5.0": "claude-opus-5", - "claude-opus-5-0": "claude-opus-5", - "opus-4.8": "claude-opus-4-8", - "claude-opus-4.8": "claude-opus-4-8", - "opus-4.7": "claude-opus-4-7", - "claude-opus-4.7": "claude-opus-4-7", - "opus-4.6": "claude-opus-4-6", - "claude-opus-4.6": "claude-opus-4-6", - "claude-opus-4-6-20251117": "claude-opus-4-6", - sonnet: "claude-sonnet-5", - "sonnet-5": "claude-sonnet-5", - "claude-sonnet-5.0": "claude-sonnet-5", - "claude-sonnet-5-0": "claude-sonnet-5", - "sonnet-4.6": "claude-sonnet-4-6", - "claude-sonnet-4.6": "claude-sonnet-4-6", - "claude-sonnet-4-6-20251117": "claude-sonnet-4-6", - haiku: "claude-haiku-4-5", - "haiku-4.5": "claude-haiku-4-5", - "claude-haiku-4.5": "claude-haiku-4-5", - "claude-haiku-4-5-20251001": "claude-haiku-4-5", - }, + [CLAUDE_DRIVER_KIND]: {}, [CURSOR_DRIVER_KIND]: { composer: "composer-2", "composer-1.5": "composer-1.5", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c3f61536fadd8..af47255a3fdc1 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -66,6 +66,8 @@ export const ServerProviderModel = Schema.Struct({ name: TrimmedNonEmptyString, shortName: Schema.optional(TrimmedNonEmptyString), subProvider: Schema.optional(TrimmedNonEmptyString), + aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + badge: Schema.optional(Schema.Literal("new")), isCustom: Schema.Boolean, isDefault: Schema.optional(Schema.Boolean), isLegacy: Schema.optional(Schema.Boolean), diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 2b27c498d1bc5..a65a60fa4cc76 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; +import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -11,8 +11,6 @@ import { getProviderOptionDescriptors, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, - normalizeCustomModelSlug, - normalizeModelSlug, } from "./model.ts"; const codexCaps: ModelCapabilities = createModelCapabilities({ @@ -148,16 +146,6 @@ describe("descriptor helpers", () => { }); }); -describe("model slug normalization", () => { - it("preserves exact custom slugs instead of expanding provider aliases", () => { - const claude = ProviderDriverKind.make("claudeAgent"); - - expect(normalizeModelSlug("fable", claude)).toBe("claude-fable-5-1"); - expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5"); - expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); - }); -}); - describe("applyClaudePromptEffortPrefix", () => { it("keeps slash commands intact when ultrathink is selected", () => { expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact"); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 3e47de3fb1d95..415ce2a465bcf 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -15,6 +15,7 @@ const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); export interface SelectableModelOption { slug: string; name: string; + aliases?: ReadonlyArray | undefined; } export function createModelCapabilities(input: { @@ -281,6 +282,13 @@ export function resolveSelectableModel( return byName.slug; } + const byAlias = options.find((option) => + option.aliases?.some((alias) => alias.toLowerCase() === trimmed.toLowerCase()), + ); + if (byAlias) { + return byAlias.slug; + } + const normalized = normalizeModelSlug(trimmed, provider); if (!normalized) { return null; From 163d50846bc8989b5858802bb02253254dcee0dd Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 16:56:09 -0400 Subject: [PATCH 031/136] Revert "fix(chat): reuse one row for live activity" (#9096) --- .../chat/MessagesTimeline.logic.test.ts | 91 ++++++++----------- .../components/chat/MessagesTimeline.logic.ts | 19 ++-- .../components/chat/MessagesTimeline.test.tsx | 42 ++------- .../src/components/chat/MessagesTimeline.tsx | 13 +-- 4 files changed, 59 insertions(+), 106 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d729ffa829f91..d9bfdae04d493 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -826,7 +826,7 @@ describe("deriveMessagesTimelineRows", () => { "assistant-final-entry", "user-followup-entry", "working-indicator-row", - "live-activity-row", + "thinking-indicator-row", ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); @@ -879,11 +879,11 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.map((row) => row.id)).toEqual([ "working-indicator-row", "assistant-thought-entry", - "live-activity-row", + "work-live:work-entry-1", ]); }); - it("keeps an actually running tool in the shared activity row", () => { + it("keeps adjacent active tool calls in one replacing row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -948,7 +948,6 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "thinking")).toBe(false); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, - active: true, groupedEntries: [ { id: "running-command" }, { id: "completed-edit" }, @@ -1197,54 +1196,40 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("reuses one activity row for initial thinking and the latest tool", () => { - const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | null) => - deriveMessagesTimelineRows({ - timelineEntries: - toolLifecycleStatus === null - ? [] - : [ - { - id: "latest-command-entry", - kind: "work", - createdAt: "2026-01-01T00:00:05Z", - entry: { - id: "latest-command", - createdAt: "2026-01-01T00:00:05Z", - turnId: "turn-1" as never, - label: toolLifecycleStatus === "inProgress" ? "Running rg" : "Ran rg", - command: "rg toolCall", - requestKind: "command", - tone: "tool" as const, - toolLifecycleStatus, - }, - }, - ], - latestTurn: { - turnId: "turn-1" as never, - state: "running", - startedAt: "2026-01-01T00:00:00Z", - completedAt: null, + it("shows thinking after the latest tool call completes while the turn is running", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "latest-command-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "latest-command", + createdAt: "2026-01-01T00:00:05Z", + turnId: "turn-1" as never, + label: "Ran rg", + command: "rg toolCall", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, }, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); - const initialRows = deriveRows(null); - const runningRows = deriveRows("inProgress"); - const completedRows = deriveRows("completed"); - const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row"); - const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row"); - const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row"); - - expect(initialActivityRow).toMatchObject({ kind: "thinking" }); - expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true }); - expect(completedActivityRow).toMatchObject({ kind: "work-live", active: false }); - expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); - expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); - expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1305,7 +1290,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("live-activity-row"); + expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -1602,8 +1587,8 @@ describe("computeStableMessagesTimelineRows", () => { initial, ); - const initialThinking = initial.byId.get("live-activity-row"); - const updatedThinking = updated.byId.get("live-activity-row"); + const initialThinking = initial.byId.get("thinking-indicator-row"); + const updatedThinking = updated.byId.get("thinking-indicator-row"); expect(initialThinking).toMatchObject({ kind: "thinking" }); expect(updatedThinking).toBe(initialThinking); expect(updated.result.at(-1)).toBe(updatedThinking); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ea1e2c7562be2..c787446f738b7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -188,8 +188,6 @@ export type TimelineLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; -const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; - export type MessagesTimelineRow = | { kind: "work"; @@ -600,16 +598,17 @@ export function deriveMessagesTimelineRows(input: { const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => workEntryIsActiveTurnActivity(entry.entry), ); + const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && latestVisibleToolEntry + activeWorkAnchor && displayedToolEntry ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { kind: "work-live" as const, - id: LIVE_ACTIVITY_ROW_ID, + id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, createdAt: activeWorkAnchor.createdAt, - entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, + entry: displayedToolEntry.entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, @@ -627,11 +626,11 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt, }); }; - let hasActivityRow = false; + let hasLiveWorkRow = false; const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); - hasActivityRow = true; + hasLiveWorkRow ||= activeWorkRow.active; if (!activeWorkRow.expanded) return; for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { nextRows.push({ @@ -731,7 +730,7 @@ export function deriveMessagesTimelineRows(input: { expanded, active: true, }); - hasActivityRow = true; + hasLiveWorkRow = true; if (expanded) { for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { nextRows.push({ @@ -833,10 +832,10 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if (input.isWorking && !hasActivityRow) { + if (input.isWorking && !hasLiveWorkRow) { nextRows.push({ kind: "thinking", - id: LIVE_ACTIVITY_ROW_ID, + id: "thinking-indicator-row", createdAt: input.activeTurnStartedAt, }); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 5835cc9fed757..024dfe69d2782 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1308,30 +1308,7 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("renders initial thinking as the shared live activity row", () => { - const turnId = TurnId.make("turn-live"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Thinking"); - expect(markup).toContain("lucide-brain"); - expect(markup).toContain('data-timeline-row-id="live-activity-row"'); - }); - - it("keeps the completed command in the shared activity row", () => { + it("keeps declined command copy visible while thinking continues", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { runningTurnId={turnId} timelineEntries={[ { - id: "entry-completed", + id: "entry-declined", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { - id: "work-completed", + id: "work-declined", createdAt: MESSAGE_CREATED_AT, turnId, - toolCallId: "call-completed", + toolCallId: "call-declined", label: "Run lint", tone: "tool", itemType: "command_execution", command: "pnpm lint", - toolLifecycleStatus: "completed", + toolLifecycleStatus: "declined", }, }, ]} />, ); - expect(markup).toContain("Ran pnpm"); - expect(markup).toContain("lucide-terminal"); - expect(markup).not.toContain("live-activity-focus"); - expect(markup).not.toContain("Running pnpm"); - expect(markup).not.toContain("Thinking"); - expect(markup).not.toContain('data-timeline-row-kind="thinking"'); + expect(markup).toContain("Declined pnpm"); + expect(markup).toContain("Thinking"); + expect(markup).toContain("tool call failed"); }); it("renders review comment contexts as structured cards instead of raw tags", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index df944a98485e4..dbe8507aef80e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -62,7 +62,6 @@ import { import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; import { BotIcon, - BrainIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, @@ -986,15 +985,14 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time : "pb-0" : isExpandedToolGroupHeader ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" + : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" ? "pb-1.5" : (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" || row.kind === "work-live" || - row.kind === "work-toggle" || - row.kind === "thinking" + row.kind === "work-toggle" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -1366,7 +1364,7 @@ function ThinkingTimelineRow() { // Reserve the activity row during setup so the handoff keeps the same height. return (
- {isPreparingWorktree ? null : } + {isPreparingWorktree ? null : }
); } @@ -2125,7 +2123,6 @@ function formatWorkingTimerNow(startIso: string): string { type WorkEntryIconName = | "bot" - | "brain" | "check" | "circle-alert" | "eye" @@ -2143,8 +2140,6 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN switch (name) { case "bot": return ; - case "brain": - return ; case "check": return ; case "circle-alert": @@ -2184,7 +2179,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { } if (tone === "thinking") { return { - iconName: "brain", + iconName: "bot", className: "text-foreground", }; } From 692eb1a5792b9930959b19805acf2bf2611318c9 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:19:27 -0400 Subject: [PATCH 032/136] fix(web): sync sidebar PR state from open panel (#9092) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 62 ++++++++++++++++++- .../components/ThreadStatusIndicators.test.ts | 56 +++++++++++++++++ .../src/components/ThreadStatusIndicators.tsx | 26 ++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c4bd71823dbe7..2783f3266d6c2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -262,6 +262,7 @@ import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; +import { linkedPullRequestDetailAtom } from "../state/pullRequests"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -317,6 +318,7 @@ import { } from "./chat/ThreadErrorBanner"; import { resolveDisplayedThreadPr, + threadPullRequestRefreshSource, threadChangeRequestSnapshotsAtom, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; @@ -1745,7 +1747,7 @@ function ChatViewContent(props: ChatViewProps) { // the tab is found again whether or not that surface was opened with an environment on it. const activePullRequestSurfaceId = activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; - const handlePullRequestTabStatusChange = useCallback( + const updatePullRequestTabStatusFromPanel = useCallback( (status: PullRequestTabStatus) => { const id = activePullRequestSurfaceId; if (id === undefined) return; @@ -1753,6 +1755,8 @@ function ChatViewContent(props: ChatViewProps) { }, [activePullRequestSurfaceId], ); + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); + const sidebarPrRefreshKeyRef = useRef(null); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); @@ -4594,6 +4598,62 @@ function ChatViewContent(props: ChatViewProps) { linkedPullRequest: linkedThreadPullRequest, linkedPullRequestStatus, }); + const handlePullRequestTabStatusChange = useCallback( + (status: PullRequestTabStatus) => { + updatePullRequestTabStatusFromPanel(status); + const source = threadPullRequestRefreshSource({ + panel: status, + thread: { + repository: threadRepository, + number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null, + state: activeThreadPr?.state ?? null, + linked: linkedThreadPullRequest !== null, + }, + }); + if (source === null) { + sidebarPrRefreshKeyRef.current = null; + return; + } + const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`; + if (sidebarPrRefreshKeyRef.current === refreshKey) return; + sidebarPrRefreshKeyRef.current = refreshKey; + + if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) { + appAtomRegistry.refresh( + linkedPullRequestDetailAtom({ + environmentId: activeThreadRef.environmentId, + input: { + projectId: linkedThreadPullRequest.projectId, + repository: linkedThreadPullRequest.repository, + number: linkedThreadPullRequest.number, + }, + }), + ); + return; + } + if (source === "vcs" && activeThreadRef && gitCwd !== null) { + void refreshVcsStatus({ + environmentId: activeThreadRef.environmentId, + input: { cwd: gitCwd }, + }).then(() => { + if (sidebarPrRefreshKeyRef.current === refreshKey) { + sidebarPrRefreshKeyRef.current = null; + } + }); + } + }, + [ + activeThreadKey, + activeThreadPr?.number, + activeThreadPr?.state, + activeThreadRef, + gitCwd, + linkedThreadPullRequest, + refreshVcsStatus, + threadRepository, + updatePullRequestTabStatusFromPanel, + ], + ); const activeThreadReferenceCopyTarget = useMemo( () => activeThreadId === null || !isServerThread diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 2663af161b552..078c3e97f5f51 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -10,6 +10,7 @@ import { resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadPullRequestRefreshSource, threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; @@ -56,6 +57,61 @@ function snapshotFor( return { branch, pr, sourceControlProvider }; } +describe("threadPullRequestRefreshSource", () => { + const panel = { repository: "pingdotgg/t3code", number: 42, state: "merged" as const }; + + it("refreshes the VCS stream when the open panel is newer than an inferred sidebar PR", () => { + expect( + threadPullRequestRefreshSource({ + panel, + thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false }, + }), + ).toBe("vcs"); + }); + + it("refreshes linked detail when the open panel is newer than a linked sidebar PR", () => { + expect( + threadPullRequestRefreshSource({ + panel, + thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: true }, + }), + ).toBe("linked-detail"); + }); + + it("refreshes when the sidebar has not resolved state yet", () => { + expect( + threadPullRequestRefreshSource({ + panel, + thread: { repository: "pingdotgg/t3code", number: 42, state: null, linked: false }, + }), + ).toBe("vcs"); + }); + + it("does nothing once sidebar state matches or the panel shows another PR", () => { + expect( + threadPullRequestRefreshSource({ + panel, + thread: { repository: "pingdotgg/t3code", number: 42, state: "merged", linked: false }, + }), + ).toBeNull(); + expect( + threadPullRequestRefreshSource({ + panel, + thread: { repository: "pingdotgg/t3code", number: 41, state: "open", linked: false }, + }), + ).toBeNull(); + }); + + it("matches repository identity without case sensitivity", () => { + expect( + threadPullRequestRefreshSource({ + panel: { ...panel, repository: "PingDotGG/T3Code" }, + thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false }, + }), + ).toBe("vcs"); + }); +}); + describe("resolveThreadPr", () => { it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { expect( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 843d310dd441c..e879c78b97101 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -39,6 +39,32 @@ export interface TerminalStatusIndicator { export type ThreadPr = VcsStatusResult["pr"]; +export type ThreadPullRequestRefreshSource = "linked-detail" | "vcs"; + +/** Refresh only when the panel has newer state for this thread's own pull request. */ +export function threadPullRequestRefreshSource(input: { + readonly panel: { + readonly repository: string; + readonly number: number; + readonly state: NonNullable["state"]; + }; + readonly thread: { + readonly repository: string | null; + readonly number: number | null; + readonly state: NonNullable["state"] | null; + readonly linked: boolean; + }; +}): ThreadPullRequestRefreshSource | null { + if ( + input.thread.repository?.toLowerCase() !== input.panel.repository.toLowerCase() || + input.thread.number !== input.panel.number || + input.thread.state === input.panel.state + ) { + return null; + } + return input.thread.linked ? "linked-detail" : "vcs"; +} + export interface LinkedThreadPullRequestStatus { readonly pr: NonNullable; readonly sourceControlProvider: NonNullable; From 3b3465f2a9dbc541a8806e39d2889abb77b93f5d Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:00:51 +0300 Subject: [PATCH 033/136] fix(web): changing projects no longer creates a draft (#9097) --- apps/web/src/components/ChatView.tsx | 1 + .../src/components/chat/DraftHeroHeadline.tsx | 39 +++- apps/web/src/composerDraftStore.test.ts | 197 ++++-------------- apps/web/src/composerDraftStore.ts | 195 ++++++----------- apps/web/src/hooks/useHandleNewThread.ts | 54 +---- .../web/src/lib/attachmentUploadQueue.test.ts | 164 +-------------- apps/web/src/lib/chatThreadActions.test.ts | 17 ++ apps/web/src/lib/chatThreadActions.ts | 19 +- 8 files changed, 175 insertions(+), 511 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2783f3266d6c2..a475230fe8299 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7367,6 +7367,7 @@ function ChatViewContent(props: ChatViewProps) { } > diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 57f9d77922516..04bbeb6ce49b1 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,11 +1,13 @@ +import type { DraftId } from "~/composerDraftStore"; +import { useComposerDraftStore } from "~/composerDraftStore"; import type { ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { FolderPlusIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; -import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useClientSettings } from "~/hooks/useSettings"; +import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; import { selectProjectGroupingSettings } from "~/logicalProject"; import { buildSidebarProjectPickerEntries, @@ -26,11 +28,13 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; interface DraftHeroHeadlineProps { + readonly draftId: DraftId | null; readonly activeProjectRef: ScopedProjectRef | null; readonly activeProjectTitle: string | null; } export function DraftHeroHeadline({ + draftId, activeProjectRef, activeProjectTitle, }: DraftHeroHeadlineProps) { @@ -40,7 +44,12 @@ export function DraftHeroHeadline({ const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); - const handleNewThread = useNewThreadHandler(); + const setLogicalProjectDraftThreadId = useComposerDraftStore( + (store) => store.setLogicalProjectDraftThreadId, + ); + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); + const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const environmentLabelById = useMemo( @@ -126,12 +135,26 @@ export function DraftHeroHeadline({ return; } const project = entry.targetProject; - // Changing the repo of a draft moves the typed content along: - // the user started writing in the wrong project, not a new task. - void handleNewThread(scopeProjectRef(project.environmentId, project.id), { - replace: true, - carryComposerContent: true, - }); + if (!draftId) { + return; + } + // Project selection changes the target of the open draft in + // place. The prompt stays in the same composer session, so the + // sidebar only gets a draft row if the user later navigates away. + const currentDraft = getComposerDraft(draftId); + setLogicalProjectDraftThreadId( + entry.group.projectKey, + scopeProjectRef(project.environmentId, project.id), + draftId, + ); + if (!hasExplicitComposerModelSelection(currentDraft)) { + applyStickyState(draftId); + if (project.defaultModelSelection) { + setModelSelection(draftId, project.defaultModelSelection, { + replaceOptions: true, + }); + } + } }} > {projectPickerEntries.map(({ group }) => { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index ac797529308d7..4e8e9d200bf67 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -636,167 +636,6 @@ describe("composerDraftStore file attachments", () => { }); }); -describe("composerDraftStore moveComposerPromptAndImages", () => { - const sourceDraftId = DraftId.make("draft-move-source"); - const destinationDraftId = DraftId.make("draft-move-destination"); - let originalRevokeObjectUrl: typeof URL.revokeObjectURL; - let revokeSpy: ReturnType void>>; - - beforeEach(() => { - resetComposerDraftStore(); - originalRevokeObjectUrl = URL.revokeObjectURL; - revokeSpy = vi.fn(); - URL.revokeObjectURL = revokeSpy; - }); - - afterEach(() => { - URL.revokeObjectURL = originalRevokeObjectUrl; - }); - - it("moves prompt and images to the destination without revoking preview URLs", () => { - const store = useComposerDraftStore.getState(); - store.setPrompt(sourceDraftId, "fix the login redirect"); - store.addImages(sourceDraftId, [makeImage({ id: "img-move", previewUrl: "blob:move" })]); - - store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId); - - expect(draftByKey(sourceDraftId)).toBeUndefined(); - const destination = draftByKey(destinationDraftId); - expect(destination?.prompt).toBe("fix the login redirect"); - expect(destination?.images.map((image) => image.id)).toEqual(["img-move"]); - expect(revokeSpy).not.toHaveBeenCalled(); - }); - - it("keeps session-bound contexts on the source and strips their placeholders from the moved prompt", () => { - const sourceThreadId = ThreadId.make("thread-move-source"); - const sourceThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, sourceThreadId); - const store = useComposerDraftStore.getState(); - store.addTerminalContext(sourceThreadRef, makeTerminalContext({ id: "ctx-stay" })); - store.setPrompt(sourceThreadRef, `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} explain this error`); - - store.moveComposerPromptAndImages(sourceThreadRef, destinationDraftId); - - const source = draftFor(sourceThreadId, TEST_ENVIRONMENT_ID); - expect(source?.terminalContexts.map((context) => context.id)).toEqual(["ctx-stay"]); - expect(source?.prompt).toBe(INLINE_TERMINAL_CONTEXT_PLACEHOLDER); - expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error"); - }); - - it("keeps hydrated file references on their original environment", () => { - const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-file-source")); - const destinationRef = scopeThreadRef( - OTHER_TEST_ENVIRONMENT_ID, - ThreadId.make("thread-file-destination"), - ); - const store = useComposerDraftStore.getState(); - store.setPrompt(sourceRef, "review the report"); - store.addFiles(sourceRef, [ - { - ...makeFile("file-hydrated"), - file: null, - uploadedAttachmentId: "pending-report-pdf", - uploadEnvironmentId: TEST_ENVIRONMENT_ID, - }, - ]); - - store.moveComposerPromptAndImages(sourceRef, destinationRef); - - expect(store.getComposerDraft(sourceRef)?.files.map((file) => file.id)).toEqual([ - "file-hydrated", - ]); - expect(store.getComposerDraft(destinationRef)?.files).toEqual([]); - expect(store.getComposerDraft(destinationRef)?.prompt).toBe("review the report"); - }); - - it("moves files across environments when the original browser file remains available", () => { - const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-file-source")); - const destinationRef = scopeThreadRef( - OTHER_TEST_ENVIRONMENT_ID, - ThreadId.make("thread-file-destination"), - ); - const store = useComposerDraftStore.getState(); - store.addFiles(sourceRef, [ - { - ...makeFile("file-local"), - uploadedAttachmentId: "pending-source-env", - uploadEnvironmentId: TEST_ENVIRONMENT_ID, - }, - ]); - - store.moveComposerPromptAndImages(sourceRef, destinationRef); - - expect(store.getComposerDraft(sourceRef)).toBeNull(); - const moved = store.getComposerDraft(destinationRef)?.files; - expect(moved?.map((file) => file.id)).toEqual(["file-local"]); - // The source-environment upload is unreachable from the destination; the - // move drops it so the destination upload can mint its own. - expect(moved?.[0]?.uploadedAttachmentId).toBeUndefined(); - expect(moved?.[0]?.uploadEnvironmentId).toBeUndefined(); - }); - - it("does not duplicate a file the destination already holds", () => { - const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-dup-source")); - const destinationRef = scopeThreadRef( - TEST_ENVIRONMENT_ID, - ThreadId.make("thread-dup-destination"), - ); - const store = useComposerDraftStore.getState(); - // Same metadata key on both sides; the ids differ. - store.addFiles(sourceRef, [makeFile("file-copy-a")]); - store.addFiles(destinationRef, [makeFile("file-copy-b")]); - - store.moveComposerPromptAndImages(sourceRef, destinationRef); - - expect(store.getComposerDraft(destinationRef)?.files.map((file) => file.id)).toEqual([ - "file-copy-b", - ]); - expect(store.getComposerDraft(sourceRef)?.files.map((file) => file.id)).toEqual([ - "file-copy-a", - ]); - }); - - it("keeps overflow attachments on the source when the destination is nearly full", () => { - const store = useComposerDraftStore.getState(); - store.addImages( - destinationDraftId, - Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) => - makeImage({ - id: `destination-${index}`, - name: `destination-${index}.png`, - previewUrl: `blob:destination-${index}`, - }), - ), - ); - store.addImages(sourceDraftId, [ - makeImage({ id: "source-first", name: "first.png", previewUrl: "blob:first" }), - makeImage({ id: "source-second", name: "second.png", previewUrl: "blob:second" }), - ]); - store.addFiles(sourceDraftId, [makeFile("source-file")]); - - store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId); - - expect(store.getComposerDraft(destinationDraftId)?.images).toHaveLength( - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - ); - expect(store.getComposerDraft(destinationDraftId)?.files).toEqual([]); - expect(store.getComposerDraft(sourceDraftId)?.images.map((image) => image.id)).toEqual([ - "source-second", - ]); - expect(store.getComposerDraft(sourceDraftId)?.files.map((file) => file.id)).toEqual([ - "source-file", - ]); - }); - - it("is a no-op when source and destination are the same target", () => { - const store = useComposerDraftStore.getState(); - store.setPrompt(sourceDraftId, "keep me"); - - store.moveComposerPromptAndImages(sourceDraftId, sourceDraftId); - - expect(draftByKey(sourceDraftId)?.prompt).toBe("keep me"); - }); -}); - describe("composerDraftStore syncPersistedAttachments", () => { const threadId = ThreadId.make("thread-sync-persisted"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); @@ -1275,6 +1114,18 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("removes a draft's previous project mapping when retargeted in place", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "keep this prompt"); + + store.setProjectDraftThreadId(otherProjectRef, draftId, { threadId }); + + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(store.getDraftThreadByProjectRef(otherProjectRef)?.draftId).toBe(draftId); + expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); + }); + it("rotates a failed bootstrap thread id without losing its draft", () => { const store = useComposerDraftStore.getState(); const retryThreadId = ThreadId.make("thread-retry"); @@ -1704,6 +1555,30 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("clears stale upload metadata when retargeting a draft to another environment", () => { + const store = useComposerDraftStore.getState(); + const hydratedFile: ComposerFileAttachment = { + ...makeFile("file-cross-environment"), + file: null, + uploadedAttachmentId: "local-environment-upload", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }; + + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.addFiles(draftId, [hydratedFile]); + + store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId }); + + const file = store.getComposerDraft(draftId)?.files[0]; + expect(file).toMatchObject({ + id: hydratedFile.id, + file: null, + }); + expect(file?.uploadedAttachmentId).toBeUndefined(); + expect(file?.uploadEnvironmentId).toBeUndefined(); + expect(file && composerFileNeedsReattach(file)).toBe(true); + }); + it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 886c89071d10d..fe1dab1997630 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -45,7 +45,6 @@ import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, - stripInlineTerminalContextPlaceholders, } from "./lib/terminalContext"; import { type ElementContextDraft, @@ -116,6 +115,29 @@ export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean return file.file === null && file.uploadedAttachmentId === undefined; } +function clearStaleFileUploadMetadata( + draft: ComposerThreadDraftState, + environmentId: EnvironmentId, +): ComposerThreadDraftState { + let changed = false; + const files = draft.files.map((file) => { + if ( + (file.uploadedAttachmentId === undefined && file.uploadEnvironmentId === undefined) || + file.uploadEnvironmentId === environmentId + ) { + return file; + } + + changed = true; + const nextFile = { ...file }; + delete nextFile.uploadedAttachmentId; + delete nextFile.uploadEnvironmentId; + return nextFile; + }); + + return changed ? { ...draft, files } : draft; +} + export const PersistedComposerFileAttachment = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -435,7 +457,11 @@ interface ComposerDraftStoreState { getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; listDraftThreadKeys: () => string[]; hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean; - /** Creates or updates the draft session tracked for a logical project. */ + /** + * Creates or updates the draft session tracked for a logical project. + * Reassigning an existing draft removes its previous logical-project + * mapping so one session cannot resolve from two projects. + */ setLogicalProjectDraftThreadId: ( logicalProjectKey: string, projectRef: ScopedProjectRef, @@ -606,13 +632,6 @@ interface ComposerDraftStoreState { * prompt stash. Session-bound context stays in the source draft. */ clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; - /** - * Moves prompt text and transferable attachments into another composer. - * Attachments over the destination limit and uploaded files that belong to - * another environment stay in the source draft. Terminal and element - * context, preview annotations, and review comments also stay in the source. - */ - moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { @@ -2515,18 +2534,53 @@ const composerDraftStore = create()( options, ); const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId; - if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) { + const hasNoStaleMappingsForDraft = Object.entries( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + ).every( + ([logicalKey, mappedDraftId]) => + mappedDraftId !== draftId || logicalKey === normalizedLogicalProjectKey, + ); + if ( + hasSameLogicalMapping && + hasNoStaleMappingsForDraft && + draftThreadsEqual(existingThread, nextDraftThread) + ) { return state; } - const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record = { - ...state.logicalProjectDraftThreadKeyByLogicalProjectKey, - [normalizedLogicalProjectKey]: draftId, - }; + // A draft session belongs to one logical project at a time. When + // an open draft is retargeted in place, remove any old mapping + // for that same draft so the previous project cannot resolve it. + const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record = + Object.fromEntries( + Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( + ([logicalKey, mappedDraftId]) => + mappedDraftId !== draftId || logicalKey === normalizedLogicalProjectKey, + ), + ); + nextLogicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey] = + draftId; const nextDraftThreadsByThreadKey: Record = { ...state.draftThreadsByThreadKey, [draftId]: nextDraftThread, }; + const existingDraft = state.draftsByThreadKey[draftId]; let nextDraftsByThreadKey = state.draftsByThreadKey; + if ( + existingThread && + existingThread.environmentId !== projectRef.environmentId && + existingDraft !== undefined + ) { + const nextDraft = clearStaleFileUploadMetadata( + existingDraft, + projectRef.environmentId, + ); + if (nextDraft !== existingDraft) { + nextDraftsByThreadKey = { + ...state.draftsByThreadKey, + [draftId]: nextDraft, + }; + } + } const previousDraftThread = previousThreadKeyForLogicalProject === undefined ? undefined @@ -2550,7 +2604,7 @@ const composerDraftStore = create()( ) { delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { - nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + nextDraftsByThreadKey = { ...nextDraftsByThreadKey }; delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject]; } } @@ -3805,117 +3859,6 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, - moveComposerPromptAndImages: (from, to) => { - const fromKey = resolveComposerDraftKey(get(), from) ?? ""; - const toKey = resolveComposerDraftKey(get(), to) ?? ""; - if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) { - return; - } - set((state) => { - const source = state.draftsByThreadKey[fromKey]; - if (!source) { - return state; - } - const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft(); - const destinationEnvironmentId = - typeof to === "string" - ? (state.draftThreadsByThreadKey[toKey]?.environmentId ?? - parseScopedThreadKey(toKey)?.environmentId ?? - null) - : to.environmentId; - // A file the destination already holds (same id, or same - // metadata key) stays behind instead of duplicating there. - const destinationFileIds = new Set(destination.files.map((file) => file.id)); - const destinationFileKeys = new Set(destination.files.map(composerFileDedupKey)); - const transferableFiles = source.files.filter( - (file) => - (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) && - !destinationFileIds.has(file.id) && - !destinationFileKeys.has(composerFileDedupKey(file)), - ); - const remainingAttachmentSlots = Math.max( - 0, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - - destination.images.length - - destination.files.length, - ); - const movedImages = source.images.slice(0, remainingAttachmentSlots); - const movedImageIds = new Set(movedImages.map((image) => image.id)); - const retainedImages = source.images.filter((image) => !movedImageIds.has(image.id)); - const movedFiles = transferableFiles - .slice(0, remainingAttachmentSlots - movedImages.length) - .map((file) => { - // A byte-backed file moving across environments re-uploads at - // the destination. Keeping the source-environment upload id - // would mark it uploaded after a reload with no local bytes - // and no valid upload anywhere the destination can reach. The - // upload queue keeps the source upload as fallback, then - // deletes it after the destination upload succeeds. Abandoned - // uploads still expire through the server sweep. - if ( - file.file === null || - file.uploadEnvironmentId === undefined || - file.uploadEnvironmentId === destinationEnvironmentId - ) { - return file; - } - const { uploadedAttachmentId: _a, uploadEnvironmentId: _e, ...rest } = file; - return rest; - }); - const movedFileIds = new Set(movedFiles.map((file) => file.id)); - const retainedFiles = source.files.filter((file) => !movedFileIds.has(file.id)); - // Inline placeholders reference the source's terminal contexts, - // which stay behind; re-anchor the moved prompt to whatever - // contexts the destination already holds. - const movedPrompt = ensureInlineTerminalContextPlaceholders( - stripInlineTerminalContextPlaceholders(source.prompt), - destination.terminalContexts.length, - ); - const nextDestination: ComposerThreadDraftState = { - ...destination, - prompt: movedPrompt, - images: [...destination.images, ...movedImages], - files: [...destination.files, ...movedFiles], - nonPersistedImageIds: [ - ...destination.nonPersistedImageIds, - ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)), - ], - persistedAttachments: [ - ...destination.persistedAttachments, - ...source.persistedAttachments.filter((attachment) => - movedImageIds.has(attachment.id), - ), - ], - }; - // Same clearing shape as clearComposerPromptAndImages, but the - // preview URLs are NOT revoked: the images moved and their blobs - // are still referenced from the destination. - const nextSource: ComposerThreadDraftState = { - ...source, - prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length), - images: retainedImages, - files: retainedFiles, - nonPersistedImageIds: source.nonPersistedImageIds.filter( - (imageId) => !movedImageIds.has(imageId), - ), - persistedAttachments: source.persistedAttachments.filter( - (attachment) => !movedImageIds.has(attachment.id), - ), - }; - const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; - if (shouldRemoveDraft(nextSource)) { - delete nextDraftsByThreadKey[fromKey]; - } else { - nextDraftsByThreadKey[fromKey] = nextSource; - } - if (shouldRemoveDraft(nextDestination)) { - delete nextDraftsByThreadKey[toKey]; - } else { - nextDraftsByThreadKey[toKey] = nextDestination; - } - return { draftsByThreadKey: nextDraftsByThreadKey }; - }); - }, }; }, { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 78e17a76b3743..438cda84c6220 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -25,6 +25,7 @@ import { import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; import { readThreadShell, useProjects, useThread } from "../state/entities"; import { + hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, } from "../lib/chatThreadActions"; @@ -33,7 +34,6 @@ import { primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; -import { toastManager } from "../components/ui/toast"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -78,14 +78,6 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; - /** - * Move the viewed draft's typed content and transferable attachments into the - * draft this request lands on. Set by the draft repo picker: the - * user started writing in the wrong project and the text should - * follow them. Explicit new-thread surfaces leave this unset and - * keep mint-fresh semantics. - */ - carryComposerContent?: boolean; }, // Which draft the thread ended up in, so a caller that has something to put in it — a // prepared checkout, a task to write — addresses that one rather than looking the project @@ -97,7 +89,6 @@ export function useNewThreadHandler() { getDraftSession, getDraftThread, applyStickyState, - moveComposerPromptAndImages, setDraftThreadContext, setLogicalProjectDraftThreadId, setModelSelection, @@ -138,39 +129,6 @@ export function useNewThreadHandler() { carrySourceShell?.interactionMode ?? carrySourceDraft?.interactionMode ?? null; - // Content only moves when the caller opted in and the user is looking - // at a draft. The content check happens at move time, not here: the - // paths below await, and text typed during those awaits must still - // come along. - const carryContentSourceDraftId = - options?.carryComposerContent === true && currentRouteTarget?.kind === "draft" - ? currentRouteTarget.draftId - : null; - const carryComposerContentTo = (destinationDraftId: DraftId) => { - if ( - carryContentSourceDraftId && - carryContentSourceDraftId !== destinationDraftId && - // Never clobber a destination the user already invested in — the - // move overwrites the destination prompt, so a concurrent repo - // change that carried content first must win. - !composerDraftHasUserContent(getComposerDraft(destinationDraftId)) && - composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId)) - ) { - moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId); - // The move caps at the destination's free slots and skips - // duplicates, so images and files can both stay behind. - const remainingDraft = getComposerDraft(carryContentSourceDraftId); - const remainingCount = - (remainingDraft?.files.length ?? 0) + (remainingDraft?.images.length ?? 0); - if (remainingCount > 0) { - toastManager.add({ - type: "warning", - title: `${remainingCount} attachment${remainingCount === 1 ? " stayed" : "s stayed"} in the original draft`, - description: "Return to the original draft or attach the files again.", - }); - } - } - }; const project = projects.find( (candidate) => candidate.id === projectRef.projectId && @@ -310,11 +268,7 @@ export function useNewThreadHandler() { // is looking at, because explicit picks are the only thing the // flag protects. const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); - const storedActiveSelection = storedDraft?.activeProvider - ? storedDraft.modelSelectionByProvider[storedDraft.activeProvider] - : undefined; - const storedDraftHasExplicitModelPick = - Boolean(storedActiveSelection) && storedDraft?.modelSelectionExplicit === true; + const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft); if (!storedDraftHasExplicitModelPick) { applyStickyState(emptyStoredDraftThread.draftId); const modelSelectionOverride = resolveModelSelectionOverride( @@ -343,7 +297,6 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); - carryComposerContentTo(emptyStoredDraftThread.draftId); const opened = { draftId: emptyStoredDraftThread.draftId, threadId: emptyStoredDraftThread.threadId, @@ -431,7 +384,6 @@ export function useNewThreadHandler() { interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), }); - carryComposerContentTo(racedDraft.draftId); await router.navigate({ to: "/draft/$draftId", params: { draftId: racedDraft.draftId }, @@ -461,8 +413,6 @@ export function useNewThreadHandler() { // state. The project default wins when both are present. setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); } - carryComposerContentTo(draftId); - await router.navigate({ to: "/draft/$draftId", params: { draftId }, diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index 9f754baf500d6..d7b30e452aab2 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -1,5 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { @@ -326,83 +325,6 @@ describe("attachmentUploadQueue", () => { } }); - it("persists a pending upload on the same-environment draft that receives its file", async () => { - const source = scopeThreadRef(firstEnvironment, ThreadId.make("thread-background-move-source")); - const destination = scopeThreadRef( - firstEnvironment, - ThreadId.make("thread-background-move-destination"), - ); - const file = makeFile("background-move"); - const store = useComposerDraftStore.getState(); - store.addFiles(source, [file]); - - try { - startAttachmentUpload({ - environmentId: firstEnvironment, - image: file, - draftTarget: source, - }); - await Promise.resolve(); - store.moveComposerPromptAndImages(source, destination); - const sourceAfterMove = store.getComposerDraft(source); - - // The destination never starts the existing job again. Its completion - // must find the file in current store state instead of the captured row. - const settled = awaitAttachmentUploads([file.id]); - TestXmlHttpRequest.requests[0]!.complete(); - await settled; - - expect(store.getComposerDraft(source)).toEqual(sourceAfterMove); - expect(store.getComposerDraft(destination)?.files).toMatchObject([ - { - id: file.id, - uploadedAttachmentId: "pending-environment-1-background-move.pdf", - uploadEnvironmentId: firstEnvironment, - }, - ]); - } finally { - store.clearComposerContent(source); - store.clearComposerContent(destination); - } - }); - - it("does not stamp an old-environment upload after its file moves environments", async () => { - const source = scopeThreadRef( - firstEnvironment, - ThreadId.make("thread-cross-environment-source"), - ); - const destination = scopeThreadRef( - secondEnvironment, - ThreadId.make("thread-cross-environment-destination"), - ); - const file = makeFile("cross-environment-move"); - const store = useComposerDraftStore.getState(); - store.addFiles(source, [file]); - - try { - startAttachmentUpload({ - environmentId: firstEnvironment, - image: file, - draftTarget: source, - }); - await Promise.resolve(); - store.moveComposerPromptAndImages(source, destination); - - const settled = awaitAttachmentUploads([file.id]); - TestXmlHttpRequest.requests[0]!.complete(); - await settled; - - expect(store.getComposerDraft(source)).toBeNull(); - const movedFile = store.getComposerDraft(destination)?.files[0]; - expect(movedFile?.id).toBe(file.id); - expect(movedFile?.uploadedAttachmentId).toBeUndefined(); - expect(movedFile?.uploadEnvironmentId).toBeUndefined(); - } finally { - store.clearComposerContent(source); - store.clearComposerContent(destination); - } - }); - it("verifies an uploaded file reference before restoring it", async () => { const file: ComposerFileAttachment = { ...makeFile("restored"), @@ -849,90 +771,6 @@ describe("attachmentUploadQueue", () => { }); }); - it("releases a moved file's source upload only after its destination upload succeeds", async () => { - const source = scopeThreadRef(firstEnvironment, ThreadId.make("thread-file-move-source")); - const destination = scopeThreadRef( - secondEnvironment, - ThreadId.make("thread-file-move-destination"), - ); - const file = makeFile("moved-report"); - const store = useComposerDraftStore.getState(); - store.addFiles(source, [file]); - - try { - startAttachmentUpload({ - environmentId: firstEnvironment, - image: file, - draftTarget: source, - }); - await Promise.resolve(); - let settled = awaitAttachmentUploads([file.id]); - TestXmlHttpRequest.requests[0]!.complete(); - await settled; - - const sourceAttachmentId = store.getComposerDraft(source)?.files[0]?.uploadedAttachmentId; - expect(sourceAttachmentId).toBe("pending-environment-1-moved-report.pdf"); - - store.moveComposerPromptAndImages(source, destination); - const movedFile = store.getComposerDraft(destination)?.files[0]; - expect(movedFile).toMatchObject({ - id: file.id, - file: file.file, - }); - expect(movedFile?.uploadedAttachmentId).toBeUndefined(); - expect(movedFile?.uploadEnvironmentId).toBeUndefined(); - - startAttachmentUpload({ - environmentId: secondEnvironment, - image: movedFile!, - draftTarget: destination, - }); - await Promise.resolve(); - - const sourceDeletesBeforeDestinationUpload = mocks.runAtomCommand.mock.calls.filter( - ([, command, target]) => - command === mocks.removeUpload && - ( - target as { - readonly environmentId: EnvironmentId; - readonly input: { readonly attachmentId: string }; - } - ).environmentId === firstEnvironment && - (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId === - sourceAttachmentId, - ); - expect(sourceDeletesBeforeDestinationUpload).toEqual([]); - - settled = awaitAttachmentUploads([file.id]); - TestXmlHttpRequest.requests[1]!.complete(); - await settled; - - const sourceDeletesAfterDestinationUpload = mocks.runAtomCommand.mock.calls.filter( - ([, command, target]) => - command === mocks.removeUpload && - ( - target as { - readonly environmentId: EnvironmentId; - readonly input: { readonly attachmentId: string }; - } - ).environmentId === firstEnvironment && - (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId === - sourceAttachmentId, - ); - expect(sourceDeletesAfterDestinationUpload).toHaveLength(1); - expect(store.getComposerDraft(destination)?.files).toMatchObject([ - { - id: file.id, - uploadedAttachmentId: "pending-environment-2-moved-report.pdf", - uploadEnvironmentId: secondEnvironment, - }, - ]); - } finally { - store.clearComposerContent(source); - store.clearComposerContent(destination); - } - }); - it("does not let stalled uploads block another environment", async () => { const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage); for (const image of images) { diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index ee555231e43c3..c145404a74e2d 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it, vi } from "vite-plus/test"; import { resolveThreadActionProjectRef, + hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, startNewThreadFromContext, @@ -37,6 +38,22 @@ function createContext(overrides: Partial = {}): ChatTh } describe("chatThreadActions", () => { + it("only treats an active stored selection marked explicit as an explicit pick", () => { + const draft = { + activeProvider: PROJECT_DEFAULT_SELECTION.instanceId, + modelSelectionByProvider: { + [PROJECT_DEFAULT_SELECTION.instanceId]: PROJECT_DEFAULT_SELECTION, + }, + modelSelectionExplicit: true, + }; + + expect(hasExplicitComposerModelSelection(draft)).toBe(true); + expect(hasExplicitComposerModelSelection({ ...draft, modelSelectionExplicit: false })).toBe( + false, + ); + expect(hasExplicitComposerModelSelection({ ...draft, activeProvider: null })).toBe(false); + }); + it("does not carry a non-explicit model from the destination draft back into itself", () => { expect( resolveNewThreadModelSelectionOverride({ diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4336fa5a825c1..c14a26d03d1c0 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -5,7 +5,12 @@ import type { ProjectId, ScopedProjectRef, } from "@t3tools/contracts"; -import type { DraftThreadEnvMode } from "../composerDraftStore"; +import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore"; + +type ComposerModelSelectionState = Pick< + ComposerThreadDraftState, + "activeProvider" | "modelSelectionByProvider" | "modelSelectionExplicit" +>; interface ThreadContextLike { environmentId: EnvironmentId; @@ -51,6 +56,18 @@ export function resolveNewThreadModelSelectionOverride(input: { ); } +export function hasExplicitComposerModelSelection( + draft: ComposerModelSelectionState | null | undefined, +): boolean { + const activeProvider = draft?.activeProvider; + return ( + draft?.modelSelectionExplicit === true && + activeProvider !== null && + activeProvider !== undefined && + draft.modelSelectionByProvider[activeProvider] !== undefined + ); +} + export function resolveThreadActionProjectRef( context: ChatThreadActionContext, ): ScopedProjectRef | null { From d0b4acbd13b2b602710e4a7d60c42f4799a409be Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:03:15 -0700 Subject: [PATCH 034/136] fix(web): keep theme placeholder text dimmer than entered text (#9104) --- apps/web/src/themePalette.test.ts | 3 +++ apps/web/src/themePalette.ts | 2 +- apps/web/src/vscodeThemeImport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 6c0718e2f1aac..562ebc2637646 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -157,6 +157,9 @@ describe("theme files", () => { expect(contrastRatio(colors.textMuted, colors.canvas)).toBeLessThan(5.5); expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeLessThan( + contrastRatio(colors.text, colors.surfaceRaised), + ); expect(colors.secondaryLabel).toBe(colors.textMuted); expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5); expect( diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 289082efef0f9..458d4539e2702 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -917,7 +917,7 @@ export function createVividThemeColors( solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker"), ); const mutedForeground = foregroundOn(mutedRgb); - const placeholder = foregroundOn(surfaceRaisedRgb); + const placeholder = themeRgbToThemeColor(readableThemeText(surfaceRaisedRgb, textRgb, 1, 4.6)); const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) }; diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index e4fcdb907abb8..59f732bf4a129 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -90,6 +90,28 @@ describe("VS Code theme import", () => { expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar); }); + it("keeps a fallback placeholder dimmer than entered text", () => { + const theme = parseVsCodeThemeFile({ + name: "Dark placeholder fallback", + type: "dark", + colors: { + "editor.background": "#1e1e2e", + "editor.foreground": "#cdd6f4", + "input.placeholderForeground": "#cdd6f473", + }, + }); + + expect( + contrastRatio(theme.colors.placeholder, theme.colors.surfaceRaised), + ).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeGreaterThanOrEqual( + 4.5, + ); + expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeLessThan( + contrastRatio(theme.colors.text, theme.colors.canvas), + ); + }); + it("fills every role the file omits with a readable derived value", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); const colors = getThemeColorsForMode(theme, "dark")!; From c0995d2eaf8ec787b3318ed1169ae266ed1529f8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 15:03:47 -0700 Subject: [PATCH 035/136] fix(web): keep the selected environment when changing projects (#9102) --- apps/web/src/environmentGrouping.test.ts | 68 ++++++++++++++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 15 +++--- docs/user/composer.md | 5 ++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 9029f1204d365..9bd7a3e924283 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -323,6 +323,74 @@ describe("environment grouping", () => { expect(entries[1]?.group.displayName).toBe("separate"); }); + it("keeps the current environment when available and falls back otherwise", () => { + const currentPrimary = makeProject({ repositoryIdentity }); + const currentRemote = makeProject({ + id: ProjectId.make("current-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const destinationRepositoryIdentity = { + canonicalKey: "github.com/example/destination", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/destination.git", + }, + }; + const destinationPrimary = makeProject({ + id: ProjectId.make("destination-primary"), + title: "destination", + workspaceRoot: "/tmp/destination", + repositoryIdentity: destinationRepositoryIdentity, + }); + const destinationRemote = makeProject({ + id: ProjectId.make("destination-remote"), + environmentId: remoteEnvironmentId, + title: "destination", + workspaceRoot: "/remote/destination", + repositoryIdentity: destinationRepositoryIdentity, + }); + const fallbackPrimary = makeProject({ + id: ProjectId.make("fallback-primary"), + title: "fallback", + workspaceRoot: "/tmp/fallback", + }); + const groups = buildSidebarProjectSnapshots({ + projects: [ + currentPrimary, + currentRemote, + destinationPrimary, + destinationRemote, + fallbackPrimary, + ], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: { + environmentId: remoteEnvironmentId, + projectId: currentRemote.id, + }, + }); + const destination = entries.find( + (entry) => entry.group.projectKey === destinationRepositoryIdentity.canonicalKey, + ); + const fallback = entries.find((entry) => entry.group.displayName === "fallback"); + + expect(destination?.targetProject).toMatchObject({ + environmentId: remoteEnvironmentId, + id: destinationRemote.id, + }); + expect(fallback?.targetProject).toMatchObject({ + environmentId: primaryEnvironmentId, + id: fallbackPrimary.id, + }); + }); + it("keeps manual project order when building grouped sidebar entries", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index be92fcaee8499..8cf3c5665aca5 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -118,22 +118,23 @@ export function buildSidebarProjectPickerEntries(input: { groups: ReadonlyArray; preferredProjectRef: ScopedProjectRef | null; }) { + const preferredProjectRef = input.preferredProjectRef; const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { - const isPreferred = input.preferredProjectRef + const isPreferred = preferredProjectRef ? group.memberProjectRefs.some( (projectRef) => - projectRef.environmentId === input.preferredProjectRef?.environmentId && - projectRef.projectId === input.preferredProjectRef.projectId, + projectRef.environmentId === preferredProjectRef.environmentId && + projectRef.projectId === preferredProjectRef.projectId, ) : false; - const preferredProject = isPreferred + const preferredProject = preferredProjectRef ? (group.memberProjects.find( (project) => - project.environmentId === input.preferredProjectRef?.environmentId && - project.id === input.preferredProjectRef?.projectId, + project.environmentId === preferredProjectRef.environmentId && + project.id === preferredProjectRef.projectId, ) ?? group.memberProjects.find( - (project) => project.environmentId === input.preferredProjectRef?.environmentId, + (project) => project.environmentId === preferredProjectRef.environmentId, )) : null; const targetProject = diff --git a/docs/user/composer.md b/docs/user/composer.md index 86525f7e1869e..97f1f5cb14f9e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -52,6 +52,11 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list when starting a thread or changing an existing thread's model. +## Changing projects + +On web and desktop, changing the project from a new thread keeps the current environment when that +project exists there. If it does not, T3 Code selects another environment that has the project. + ## Notices above the composer On web and desktop, loading and syncing statuses fill the available banner width beside the From 0222aa255d11babd242dbe5ed0947e5fc5eaefee Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:04:33 +0200 Subject: [PATCH 036/136] fix(web): preserve theme when toggling advanced colors (#8500) --- apps/web/src/components/settings/ThemeEditorPanel.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 0bb0d1b0ec18a..9ac264cd00d3e 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -308,6 +308,7 @@ export function ThemeEditorPanel({ const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState< Record >({ light: false, dark: false }); + const [shouldRegenerateGuidedColors, setShouldRegenerateGuidedColors] = useState(false); const [error, setError] = useState(null); const [isMinimized, setIsMinimized] = useState(false); const [roleQuery, setRoleQuery] = useState(""); @@ -401,6 +402,10 @@ export function ThemeEditorPanel({ // regenerate when the guided editor produced it. setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true); setSimpleColorsDirtyByAppearance({ light: false, dark: false }); + // An unmanaged palette needs conversion when the user opts into the + // guided editor. Merely revealing Advanced for a managed/default draft + // must stay read-only until a color changes. + setShouldRegenerateGuidedColors(sourceTheme !== null && sourceTheme.managed !== true); setColorsByAppearance(nextColors); setSelectedRole(null); setUsageCount(null); @@ -497,6 +502,7 @@ export function ThemeEditorPanel({ [activeAppearance]: true, })); } + if (isAdvanced) setShouldRegenerateGuidedColors(true); }, [activeAppearance, isAdvanced], ); @@ -735,6 +741,7 @@ export function ThemeEditorPanel({ if (selectedRole && !THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)) { setSelectedRole(null); } + if (!shouldRegenerateGuidedColors) return; // Regenerate every appearance the theme will save, not just the visible // one, so the palettes shown after toggling match what gets saved. @@ -754,8 +761,9 @@ export function ThemeEditorPanel({ } return next; }); + setShouldRegenerateGuidedColors(false); }, - [activeAppearance, editingTheme, selectedRole], + [activeAppearance, editingTheme, selectedRole, shouldRegenerateGuidedColors], ); const handleSubmit = () => { From 590a579f2e9292ce314c69e459e19620004578fe Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 18:06:21 -0400 Subject: [PATCH 037/136] fix(chat): keep latest command live between messages (#9098) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../chat/MessagesTimeline.logic.test.ts | 95 +++++++++++-------- .../components/chat/MessagesTimeline.logic.ts | 28 ++++-- .../components/chat/MessagesTimeline.test.tsx | 42 ++++++-- .../src/components/chat/MessagesTimeline.tsx | 13 ++- 4 files changed, 118 insertions(+), 60 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d9bfdae04d493..d702d6ddda742 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -826,7 +826,7 @@ describe("deriveMessagesTimelineRows", () => { "assistant-final-entry", "user-followup-entry", "working-indicator-row", - "thinking-indicator-row", + "live-activity-row", ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); @@ -879,11 +879,11 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.map((row) => row.id)).toEqual([ "working-indicator-row", "assistant-thought-entry", - "work-live:work-entry-1", + "live-activity-row", ]); }); - it("keeps adjacent active tool calls in one replacing row", () => { + it("keeps an actually running tool in the shared activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -948,6 +948,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "thinking")).toBe(false); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, + active: true, groupedEntries: [ { id: "running-command" }, { id: "completed-edit" }, @@ -1196,40 +1197,58 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("shows thinking after the latest tool call completes while the turn is running", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "latest-command-entry", - kind: "work", - createdAt: "2026-01-01T00:00:05Z", - entry: { - id: "latest-command", - createdAt: "2026-01-01T00:00:05Z", - turnId: "turn-1" as never, - label: "Ran rg", - command: "rg toolCall", - requestKind: "command", - tone: "tool" as const, - toolLifecycleStatus: "completed" as const, - }, + it("reuses one activity row for initial thinking and the latest tool", () => { + const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) => + deriveMessagesTimelineRows({ + timelineEntries: + toolLifecycleStatus === null + ? [] + : [ + { + id: "latest-command-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "latest-command", + createdAt: "2026-01-01T00:00:05Z", + turnId: "turn-1" as never, + label: toolLifecycleStatus === "inProgress" ? "Running rg" : "Ran rg", + command: "rg toolCall", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, }, - ], - latestTurn: { - turnId: "turn-1" as never, - state: "running", - startedAt: "2026-01-01T00:00:00Z", - completedAt: null, - }, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); - expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); - expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); - expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); + const initialRows = deriveRows(null); + const runningRows = deriveRows("inProgress"); + const completedRows = deriveRows("completed"); + const declinedRows = deriveRows("declined"); + const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row"); + const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row"); + const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row"); + + expect(initialActivityRow).toMatchObject({ kind: "thinking" }); + expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); + expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); + expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1290,7 +1309,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); + expect(rows.map((row) => row.id)).toContain("live-activity-row"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -1587,8 +1606,8 @@ describe("computeStableMessagesTimelineRows", () => { initial, ); - const initialThinking = initial.byId.get("thinking-indicator-row"); - const updatedThinking = updated.byId.get("thinking-indicator-row"); + const initialThinking = initial.byId.get("live-activity-row"); + const updatedThinking = updated.byId.get("live-activity-row"); expect(initialThinking).toMatchObject({ kind: "thinking" }); expect(updatedThinking).toBe(initialThinking); expect(updated.result.at(-1)).toBe(updatedThinking); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c787446f738b7..9f8794801854d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -15,6 +15,7 @@ export { import { formatDuration, workEntryDisplayIndicatesToolFailure, + workEntryIndicatesToolSuccess, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, @@ -188,6 +189,8 @@ export type TimelineLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; +const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + export type MessagesTimelineRow = | { kind: "work"; @@ -598,21 +601,26 @@ export function deriveMessagesTimelineRows(input: { const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => workEntryIsActiveTurnActivity(entry.entry), ); - const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; + const latestToolKeepsActivityLive = + latestRunningToolEntry !== undefined || + (latestVisibleToolEntry !== undefined && + workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && displayedToolEntry + activeWorkAnchor && latestVisibleToolEntry ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { kind: "work-live" as const, - id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, + id: latestToolKeepsActivityLive + ? LIVE_ACTIVITY_ROW_ID + : `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, createdAt: activeWorkAnchor.createdAt, - entry: displayedToolEntry.entry, + entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, - active: latestRunningToolEntry !== undefined, + active: latestToolKeepsActivityLive, }; })() : null; @@ -626,11 +634,11 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt, }); }; - let hasLiveWorkRow = false; + let hasActivityRow = false; const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); - hasLiveWorkRow ||= activeWorkRow.active; + hasActivityRow ||= activeWorkRow.active; if (!activeWorkRow.expanded) return; for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { nextRows.push({ @@ -730,7 +738,7 @@ export function deriveMessagesTimelineRows(input: { expanded, active: true, }); - hasLiveWorkRow = true; + hasActivityRow = true; if (expanded) { for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { nextRows.push({ @@ -832,10 +840,10 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if (input.isWorking && !hasLiveWorkRow) { + if (input.isWorking && !hasActivityRow) { nextRows.push({ kind: "thinking", - id: "thinking-indicator-row", + id: LIVE_ACTIVITY_ROW_ID, createdAt: input.activeTurnStartedAt, }); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 024dfe69d2782..5c6dbbf5b2e16 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1308,7 +1308,30 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("keeps declined command copy visible while thinking continues", () => { + it("renders initial thinking as the shared live activity row", () => { + const turnId = TurnId.make("turn-live"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Thinking"); + expect(markup).toContain("lucide-brain"); + expect(markup).toContain('data-timeline-row-id="live-activity-row"'); + }); + + it("keeps the completed command in the shared activity row", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { runningTurnId={turnId} timelineEntries={[ { - id: "entry-declined", + id: "entry-completed", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { - id: "work-declined", + id: "work-completed", createdAt: MESSAGE_CREATED_AT, turnId, - toolCallId: "call-declined", + toolCallId: "call-completed", label: "Run lint", tone: "tool", itemType: "command_execution", command: "pnpm lint", - toolLifecycleStatus: "declined", + toolLifecycleStatus: "completed", }, }, ]} />, ); - expect(markup).toContain("Declined pnpm"); - expect(markup).toContain("Thinking"); - expect(markup).toContain("tool call failed"); + expect(markup).toContain("Running pnpm"); + expect(markup).toContain("lucide-terminal"); + expect(markup).toContain("live-activity-focus"); + expect(markup).not.toContain("Ran pnpm"); + expect(markup).not.toContain("Thinking"); + expect(markup).not.toContain('data-timeline-row-kind="thinking"'); }); it("renders review comment contexts as structured cards instead of raw tags", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index dbe8507aef80e..df944a98485e4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -62,6 +62,7 @@ import { import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; import { BotIcon, + BrainIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, @@ -985,14 +986,15 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time : "pb-0" : isExpandedToolGroupHeader ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" + : row.kind === "turn-fold" || row.kind === "working" ? "pb-1.5" : (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" || row.kind === "work-live" || - row.kind === "work-toggle" + row.kind === "work-toggle" || + row.kind === "thinking" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -1364,7 +1366,7 @@ function ThinkingTimelineRow() { // Reserve the activity row during setup so the handoff keeps the same height. return (
- {isPreparingWorktree ? null : } + {isPreparingWorktree ? null : }
); } @@ -2123,6 +2125,7 @@ function formatWorkingTimerNow(startIso: string): string { type WorkEntryIconName = | "bot" + | "brain" | "check" | "circle-alert" | "eye" @@ -2140,6 +2143,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN switch (name) { case "bot": return ; + case "brain": + return ; case "check": return ; case "circle-alert": @@ -2179,7 +2184,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { } if (tone === "thinking") { return { - iconName: "bot", + iconName: "brain", className: "text-foreground", }; } From 60cef47ec983637ddc68faed7b1488b6f3c3a175 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:14:49 +0000 Subject: [PATCH 038/136] chore(release): prepare v0.0.38 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b4d6e8d739589..83a07cccb660d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.37", + "version": "0.0.38", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 3f7ae60964613..073008d917a5f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.37", + "version": "0.0.38", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 21e051c4bce6a..0a9723e191295 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.37", + "version": "0.0.38", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index c0d916a0baae5..dbd8ee3744e8f 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.37", + "version": "0.0.38", "private": true, "files": [ "dist" From beae2147a9487ec47ac992319f2216914b4cb62d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 1 Sep 2026 16:05:15 -0700 Subject: [PATCH 039/136] fix(media): preview host files and stream videos across clients (#9023) --- .../src/electron/ElectronProtocol.test.ts | 2 +- apps/desktop/src/electron/ElectronProtocol.ts | 4 +- .../assets/file-icons/pierre_video.png | Bin 0 -> 676 bytes .../modules/t3-markdown-text/package.json | 2 + .../scripts/sync-pierre-file-icons.mjs | 2 + .../src/markdownFileIcons.generated.ts | 1 + .../t3-markdown-text/src/markdownLinks.ts | 31 +- .../src/nativeMarkdownText.ts | 19 +- .../mobile/src/components/FilePreview.ios.tsx | 14 +- apps/mobile/src/components/FilePreview.tsx | 2 + .../src/components/FilePreviewModal.tsx | 16 +- .../src/components/MediaActionsMenu.tsx | 43 ++ .../src/components/MediaImagePreview.tsx | 61 +++ .../src/components/MediaSourceCaption.tsx | 19 + .../src/components/MediaVideoPlayer.tsx | 187 ++++++++ .../src/components/MediaVideoPreviewModal.tsx | 96 ++++ .../src/components/VideoPreviewModal.ios.tsx | 10 +- .../src/components/VideoPreviewModal.tsx | 18 +- .../src/components/VideoThumbnailImage.tsx | 3 +- .../features/files/ThreadFilesRouteScreen.tsx | 135 +++++- .../files/WorkspaceFileImagePreview.tsx | 14 +- .../files/WorkspaceFileVideoPreview.tsx | 47 ++ apps/mobile/src/features/files/filePath.ts | 5 + .../features/files/preload-workspace-file.ts | 8 +- .../features/files/workspaceFileAssetUrl.ts | 30 +- .../src/features/threads/ThreadFeed.tsx | 358 +++++++++++---- apps/mobile/src/lib/markdownLinks.test.ts | 29 +- apps/mobile/src/lib/markdownMedia.test.ts | 77 ++++ apps/mobile/src/lib/markdownMedia.ts | 89 ++++ apps/mobile/src/lib/mediaActions.ts | 121 +++++ .../mobile/src/lib/nativeMarkdownText.test.ts | 37 ++ apps/mobile/src/lib/videoPreviewSource.ts | 54 +++ apps/mobile/src/state/assets.ts | 22 + .../mobile/src/state/use-atom-query-runner.ts | 8 +- apps/server/src/assets/AssetAccess.test.ts | 335 ++++++++++++++ apps/server/src/assets/AssetAccess.ts | 84 ++++ apps/server/src/assets/MediaFile.ts | 113 +++++ apps/server/src/http.test.ts | 176 +++++++- apps/server/src/http.ts | 56 ++- apps/web/src/assets/assetUrls.ts | 19 +- apps/web/src/components/ChatMarkdown.tsx | 417 +++++++++++++++--- .../ChatMarkdown.workspace-images.test.tsx | 13 +- .../web/src/components/ChatView.logic.test.ts | 18 - apps/web/src/components/ChatView.logic.ts | 39 +- apps/web/src/components/ChatView.tsx | 100 ++--- apps/web/src/components/chat/ChatComposer.tsx | 5 +- .../chat/ExpandedImageDialog.test.tsx | 28 -- .../components/chat/ExpandedImageDialog.tsx | 177 ++++---- .../chat/ExpandedImagePreview.test.ts | 63 +-- .../components/chat/ExpandedImagePreview.tsx | 121 ++++- .../src/components/chat/MessagesTimeline.tsx | 1 + .../chat/externalLinkContextMenu.test.ts | 2 + .../chat/externalLinkContextMenu.ts | 2 +- .../src/components/files/FilePreviewPanel.tsx | 138 +++++- .../files/projectFilesQueryState.ts | 14 +- .../web/src/components/media/MediaActions.tsx | 189 ++++++++ .../src/components/media/MediaVideoPlayer.tsx | 197 +++++++++ .../src/components/media/OpenMediaLink.tsx | 45 ++ apps/web/src/components/media/mediaContent.ts | 82 ++++ .../pullRequest/PullRequestMarkdown.tsx | 6 +- apps/web/src/contextMenuFallback.ts | 4 + apps/web/src/lib/videoFirstFrame.test.ts | 83 ++++ apps/web/src/lib/videoFirstFrame.ts | 28 ++ apps/web/src/markdown-links.test.ts | 1 + apps/web/src/markdown-links.ts | 160 +------ apps/web/src/pierre-icons.ts | 11 + apps/web/src/state/use-atom-query-runner.ts | 8 +- docs/internals/environment-auth.md | 34 ++ docs/user/composer.md | 44 +- packages/client-runtime/package.json | 8 + packages/client-runtime/src/markdownImages.ts | 2 +- .../client-runtime/src/markdownLinks.test.ts | 30 ++ packages/client-runtime/src/markdownLinks.ts | 158 +++++++ .../client-runtime/src/mediaReference.test.ts | 39 ++ packages/client-runtime/src/mediaReference.ts | 95 ++++ .../src/work-log/presentation.test.ts | 2 +- .../src/work-log/presentation.ts | 4 +- packages/contracts/src/assets.ts | 16 +- packages/shared/src/filePreview.test.ts | 27 ++ packages/shared/src/filePreview.ts | 58 +++ packages/shared/src/video.ts | 2 + pnpm-lock.yaml | 8 +- 82 files changed, 4144 insertions(+), 682 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png create mode 100644 apps/mobile/src/components/MediaActionsMenu.tsx create mode 100644 apps/mobile/src/components/MediaImagePreview.tsx create mode 100644 apps/mobile/src/components/MediaSourceCaption.tsx create mode 100644 apps/mobile/src/components/MediaVideoPlayer.tsx create mode 100644 apps/mobile/src/components/MediaVideoPreviewModal.tsx create mode 100644 apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx create mode 100644 apps/mobile/src/lib/markdownMedia.test.ts create mode 100644 apps/mobile/src/lib/markdownMedia.ts create mode 100644 apps/mobile/src/lib/mediaActions.ts create mode 100644 apps/mobile/src/lib/videoPreviewSource.ts create mode 100644 apps/server/src/assets/MediaFile.ts delete mode 100644 apps/web/src/components/chat/ExpandedImageDialog.test.tsx create mode 100644 apps/web/src/components/media/MediaActions.tsx create mode 100644 apps/web/src/components/media/MediaVideoPlayer.tsx create mode 100644 apps/web/src/components/media/OpenMediaLink.tsx create mode 100644 apps/web/src/components/media/mediaContent.ts create mode 100644 apps/web/src/lib/videoFirstFrame.test.ts create mode 100644 apps/web/src/lib/videoFirstFrame.ts create mode 100644 packages/client-runtime/src/markdownLinks.test.ts create mode 100644 packages/client-runtime/src/markdownLinks.ts create mode 100644 packages/client-runtime/src/mediaReference.test.ts create mode 100644 packages/client-runtime/src/mediaReference.ts diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 585dd7e7ea1e7..a5c03e0b93362 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -225,7 +225,7 @@ describe("ElectronProtocol", () => { "http:", "https:", ]); - assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]); + assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]); assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 845f03dba5da7..fabd598d7ffab 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -87,7 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat `script-src ${scriptSources.join(" ")}`, `connect-src ${connectSources.join(" ")}`, `img-src 'self' ${input.scheme}: blob: data: http: https:`, - `media-src 'self' ${input.scheme}: blob:`, + `media-src 'self' ${input.scheme}: blob: http: https:`, "style-src 'self' 'unsafe-inline'", `font-src 'self' ${input.scheme}: data:`, "worker-src 'self' blob:", @@ -118,6 +118,7 @@ export function registerDesktopSchemePrivilegesSync(): void { secure: true, supportFetchAPI: true, corsEnabled: true, + stream: true, }, }, { @@ -127,6 +128,7 @@ export function registerDesktopSchemePrivilegesSync(): void { secure: true, supportFetchAPI: true, corsEnabled: true, + stream: true, }, }, ]); diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png new file mode 100644 index 0000000000000000000000000000000000000000..673c95d8b36a84ea316c04840d0018b0d3101175 GIT binary patch literal 676 zcmV;V0$crwP)_cp%VRh3TQQO00pm@%`VxUu0P4A9j!gzcs!mNPnJ0x4u|7$(B{WA0^K8! z14bR*7lO$b0{zj)$&69Y=D$}cXF*5M6?8tdLk<{p_oy>n&8kB0_2dPC3<%|#QU9hO z!B#@$;rjS_sm>2J#$5*aU3Z5TmSB756P6kIKeczF3KDR%QkVb&8PslkMM9`lJm6u@ zoCmp>3qFQY`CNSfy!ofq!Mh-c@-?T;Reyc*Dw+TW-8Gp6-Fwj1QrMNPLX5}Q)Riqu z*L*ud-Om}6H}T3)4vo&A+ijagK!D{5VTp`ZMxZB#>widvfMBaJfG6?F(45=DWC$o= zzX>M*-2;Y36urz@qH3K2IZNhcsVP9Hqf*JoatVk{hDPzqP{03g1cW|00kI&T0^05% z$i57!XM*ghu=-wreKVxK8{|cnL3n~AvD<$pvKPSS$#VAs*hpF8UH}s(fP4yQyMrKm zDy*IgbEFHIZt2J3;`ahs#qf)fZtXWi(w-wR@)aq^0E-)gvuo|uf_<0^s=I09IYh%R^ZNKfA%(}&k7R|5FQ((Oag)aTKK`leNPDW z_9;D?xs6|Pyzt0d2=(@%") ? trimmed.slice(1, -1) : trimmed; } +/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ +export function normalizeNativeMarkdownUrl(value: string): string { + return value.startsWith("//") ? `https:${value}` : value; +} + function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { try { const parsed = new URL(href); if (parsed.protocol.toLowerCase() !== "file:") { return null; } - const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) - ? parsed.pathname.slice(1) + const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; + const rawPath = uncHostname + ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` : parsed.pathname; + const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; return { path, hash: parsed.hash }; } catch { return null; @@ -327,6 +340,7 @@ function looksLikeFilePath(value: string): boolean { if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { return true; } + if (isConventionalFilePosition(value)) return true; return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); } @@ -338,6 +352,7 @@ function fileLabel(value: string): string { export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; const exactIcon = FILE_ICON_BY_NAME[basename]; if (exactIcon) return exactIcon; if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { @@ -354,7 +369,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { const normalized = normalizeDestination(href); try { - const parsed = new URL(normalized); + const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); if (parsed.protocol === "http:" || parsed.protocol === "https:") { return { kind: "external", @@ -399,3 +414,13 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null, }; } + +/** Backticks become file references only when the shared path heuristic recognizes the whole span. */ +export function resolveMarkdownInlineCodePresentation( + content: string, +): Extract | null { + const candidate = inlineCodeFilePathCandidate(content); + if (candidate === null) return null; + const presentation = resolveMarkdownLinkPresentation(candidate); + return presentation.kind === "file" ? presentation : null; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 8db904b5a6cad..2b39ac201599a 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -1,7 +1,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; -import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; +import { + resolveMarkdownInlineCodePresentation, + resolveMarkdownLinkPresentation, + type MarkdownFileIcon, +} from "./markdownLinks"; export interface NativeMarkdownTextRun { readonly text: string; @@ -283,8 +287,17 @@ function appendNode( return appendRun(runs, textNodeContent(nodeTextContent(node)), context); case "html_inline": return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); - case "code_inline": - return appendRun(runs, nodeTextContent(node), { ...context, code: true }); + case "code_inline": { + const content = nodeTextContent(node); + const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content); + return presentation + ? appendRun(runs, presentation.label, { + ...context, + href: presentation.href, + fileIcon: presentation.icon, + }) + : appendRun(runs, content, { ...context, code: true }); + } case "soft_break": return appendRun(runs, " ", context); case "line_break": diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index c7740104fe6a9..c2f5a6d72cc71 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -3,6 +3,7 @@ import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaImagePreview } from "./MediaImagePreview"; const NativeControls = requireNativeModule<{ presentFile( @@ -14,7 +15,7 @@ const NativeControls = requireNativeModule<{ dismissFile(identifier: string): Promise; }>("T3NativeControls"); -export function FilePreview(props: { +function NativeFilePreview(props: { readonly source: ResolvedFilePreviewSource; readonly onRequestClose: () => void; }) { @@ -41,3 +42,14 @@ export function FilePreview(props: { return null; } + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + return props.source.kind === "image" && props.source.actionsSource ? ( + + ) : ( + + ); +} diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index f10bfb8b3e63b..8240c4a6ad386 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -4,6 +4,7 @@ import ImageViewing from "react-native-image-viewing"; import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaImagePreview } from "./MediaImagePreview"; function PdfPreview(props: { readonly source: ResolvedFilePreviewSource; @@ -39,6 +40,7 @@ export function FilePreview(props: { readonly onRequestClose: () => void; }) { if (props.source.kind === "pdf") return ; + if (props.source.actionsSource) return ; return ( & @@ -40,13 +43,18 @@ function ResolvedFilePreview(props: { (connection._tag === "None" || asset._tag === "Failure"); useEffect(() => Keyboard.dismiss(), []); useEffect(() => { - if (uri === null && asset._tag === "Success") setUri(asset.url); - }, [uri, asset]); + if (uri === null && asset._tag === "Success") setUri(asset.url + (source.srcFragment ?? "")); + }, [uri, asset, source.srcFragment]); useEffect(() => { if (!failed) return; - Alert.alert("Could not open preview", "Reconnect to this environment and try again."); + Alert.alert( + "Could not open preview", + connection._tag === "None" + ? "Reconnect to this environment and try again." + : "The file could not be loaded. It may have been moved or deleted.", + ); onRequestClose(); - }, [failed]); + }, [failed, connection._tag]); useEffect(() => { if (!("attachment" in source)) return; const controller = new AbortController(); diff --git a/apps/mobile/src/components/MediaActionsMenu.tsx b/apps/mobile/src/components/MediaActionsMenu.tsx new file mode 100644 index 0000000000000..a4b44e85258f2 --- /dev/null +++ b/apps/mobile/src/components/MediaActionsMenu.tsx @@ -0,0 +1,43 @@ +import { MenuView } from "@react-native-menu/menu"; +import type { ReactElement } from "react"; +import { Platform, View, type PressableProps } from "react-native"; + +import type { useMediaActions } from "../lib/mediaActions"; +import { SymbolView } from "./AppSymbol"; +import { ControlPillMenu } from "./ControlPill"; + +export function MediaActionsMenu(props: { + readonly media: ReturnType; + readonly inModal?: boolean; + readonly children?: ReactElement; +}) { + if (props.media.actions.length === 0) return props.children ?? null; + // Android's normal anchored menu lives in the app-root portal, behind native modals. + const nativeAndroidMenu = props.inModal && Platform.OS === "android"; + const Menu = nativeAndroidMenu ? MenuView : ControlPillMenu; + return ( + ({ + id, + title, + attributes: { disabled: disabled ?? false }, + }))} + onPressAction={({ nativeEvent }) => { + props.media.actions.find(({ id }) => id === nativeEvent.event)?.run(); + }} + > + {props.children ?? ( + + + + )} + + ); +} diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx new file mode 100644 index 0000000000000..5bdc9140ddc9b --- /dev/null +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext } from "react"; +import { Pressable, View } from "react-native"; +import ImageViewing from "react-native-image-viewing"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useMediaActions } from "../lib/mediaActions"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { MediaSourceCaption } from "./MediaSourceCaption"; + +type MediaImagePreviewProps = { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}; + +const ImagePreviewContext = createContext(null); + +function ImagePreviewHeader() { + const props = useContext(ImagePreviewContext)!; + const insets = useSafeAreaInsets(); + const mediaActions = useMediaActions(props.source.actionsSource, props.onRequestClose); + return ( + + + + {props.source.name ?? "Image"} + + + + + + + + + ); +} + +/** Chat and workspace media retain source actions on both platforms; other files use native previews. */ +export function MediaImagePreview(props: MediaImagePreviewProps) { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/MediaSourceCaption.tsx b/apps/mobile/src/components/MediaSourceCaption.tsx new file mode 100644 index 0000000000000..76c24290d3477 --- /dev/null +++ b/apps/mobile/src/components/MediaSourceCaption.tsx @@ -0,0 +1,19 @@ +import { ScrollView } from "react-native"; + +import { AppText } from "./AppText"; + +/** Keep the original reference readable without letting long URLs displace the preview. */ +export function MediaSourceCaption(props: { readonly source: string | undefined }) { + if (!props.source) return null; + return ( + + + {props.source} + + + ); +} diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx new file mode 100644 index 0000000000000..a065f75e13961 --- /dev/null +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -0,0 +1,187 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEvent } from "expo"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { ActivityIndicator, AppState, Pressable, View } from "react-native"; + +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { VideoThumbnailImage } from "./VideoThumbnailImage"; +import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; +import { MediaActionsMenu } from "./MediaActionsMenu"; + +/** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */ +function LoadedMediaVideo(props: { + readonly uri: string; + readonly resolvePlaybackUri?: () => Promise; + readonly playRequested: boolean; + readonly paused: boolean; +}) { + const focused = useIsFocused(); + const active = useRef(focused && AppState.currentState === "active"); + const [attempt, setAttempt] = useState(0); + // Expo's Android player also reports completed playback as idle. + const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending"); + const player = useVideoPlayer(null, (player) => { + player.staysActiveInBackground = false; + player.bufferOptions = { preferredForwardBufferDuration: 5 }; + }); + const { status } = useEvent(player, "statusChange", { status: player.status }); + const loadSource = useEffectEvent(async (signal: AbortSignal) => { + const uri = props.resolvePlaybackUri ? await props.resolvePlaybackUri() : props.uri; + if (signal.aborted) return; + if (uri === null) throw new Error("Video unavailable"); + player.pause(); + await player.replaceAsync({ uri, contentType: "progressive" }); + if (!signal.aborted && props.playRequested && active.current) player.play(); + }); + + useEffect(() => { + active.current = focused && !props.paused && AppState.currentState === "active"; + if (!active.current) player.pause(); + const subscription = AppState.addEventListener("change", (state) => { + active.current = focused && !props.paused && state === "active"; + if (!active.current) player.pause(); + }); + return () => subscription.remove(); + }, [focused, player, props.paused]); + + useEffect(() => { + const controller = new AbortController(); + setLoadState("pending"); + // A renewed signature is used on Retry, not as a reason to reset the native player. + void loadSource(controller.signal).then( + () => { + if (!controller.signal.aborted) setLoadState("complete"); + }, + () => { + if (!controller.signal.aborted) setLoadState("error"); + }, + ); + return () => controller.abort(); + }, [player, props.playRequested, attempt]); + + return ( + + + {loadState === "error" || (loadState === "complete" && status === "error") ? ( + + Video unavailable + setAttempt((value) => value + 1)} + className="min-h-11 justify-center px-4" + > + Retry + + + ) : loadState === "pending" || status === "loading" ? ( + + + + ) : null} + + ); +} + +interface MediaVideoPlayerProps { + readonly uri: string | null; + readonly resolvePlaybackUri?: () => Promise; + readonly name: string; + readonly thumbnailKey: string; + readonly thumbnailVisible?: boolean; + readonly unavailable?: boolean; + readonly expanded?: boolean; + readonly paused?: boolean; + readonly onExpand?: () => void; + readonly actionsSource?: MediaActionsSource; +} + +function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { + const mediaActions = useMediaActions(props.actionsSource); + const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); + // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. + if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + + return ( + + {playbackUri ? ( + + ) : ( + setPlaybackUri(props.uri)} + className="flex-1 items-center justify-center gap-2 px-4" + > + {!props.unavailable ? ( + + ) : null} + {props.unavailable ? ( + Video unavailable + ) : props.uri === null ? ( + + ) : ( + <> + + + + + {props.name} + + + )} + + )} + {props.onExpand ? ( + { + setPlaybackUri(null); + props.onExpand?.(); + }} + className="absolute right-1 top-1 min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60 px-2" + > + Expand + + ) : null} + {props.actionsSource ? ( + + + + ) : null} + + ); +} + +export function MediaVideoPlayer(props: MediaVideoPlayerProps) { + return ; +} diff --git a/apps/mobile/src/components/MediaVideoPreviewModal.tsx b/apps/mobile/src/components/MediaVideoPreviewModal.tsx new file mode 100644 index 0000000000000..6c231194701cd --- /dev/null +++ b/apps/mobile/src/components/MediaVideoPreviewModal.tsx @@ -0,0 +1,96 @@ +import { useEffect } from "react"; +import { Keyboard, Modal, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useMediaActions } from "../lib/mediaActions"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type MediaVideoPreviewSource, +} from "../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { MediaVideoPlayer } from "./MediaVideoPlayer"; +import { MediaSourceCaption } from "./MediaSourceCaption"; + +/** Media files stream in place. A client-side copy is made only for an explicit share. */ +export function MediaVideoPreviewModal(props: { + readonly source: MediaVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const insets = useSafeAreaInsets(); + const environmentId = "environmentId" in source ? source.environmentId : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); + const refreshAssetUrl = useRefreshAssetUrl( + environmentId, + "resource" in source ? source.resource : null, + ); + const resolvePlaybackUri = + "resource" in source + ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) + : undefined; + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + const mediaActions = useMediaActions(source.actionsSource, props.onRequestClose); + const unavailable = + uri === null && + environmentId !== null && + (connection._tag === "None" || asset._tag === "Failure"); + + useEffect(() => Keyboard.dismiss(), []); + return ( + + + + + {source.name} + + + + + + + + + + + {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} + + + + + ); +} diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index a947d8d2e51cc..88a1b5191dd05 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -7,9 +7,10 @@ import { Alert, Keyboard } from "react-native"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { useAssetUrlState } from "../state/assets"; import { usePreparedConnection } from "../state/session"; -import type { VideoPreviewSource } from "./VideoPreviewModal"; +import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; +import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; -export type { VideoPreviewSource } from "./VideoPreviewModal"; +export type { VideoPreviewSource } from "../lib/videoPreviewSource"; const NativeControls = requireNativeModule<{ presentVideo( @@ -22,7 +23,7 @@ const NativeControls = requireNativeModule<{ }>("T3NativeControls"); function NativeVideoPreview(props: { - readonly source: VideoPreviewSource; + readonly source: AttachmentVideoPreviewSource; readonly onRequestClose: () => void; }) { const { source } = props; @@ -117,5 +118,8 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource]); if (!props.source || !isFocused) return null; + if (props.source.type === "media") { + return ; + } return ; } diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index eaa01c5d1714f..cc56b3952b75a 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -1,5 +1,4 @@ import { useIsFocused } from "@react-navigation/native"; -import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; @@ -19,21 +18,15 @@ import { downloadAttachmentForPreview, type AttachmentPreviewFile, } from "../lib/attachmentDownload"; -import type { DraftComposerFileAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; import { useAssetUrlState } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { SymbolView } from "./AppSymbol"; import { AppText } from "./AppText"; +import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; -export type VideoPreviewSource = ( - | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } - | { - readonly type: "remote"; - readonly environmentId: EnvironmentId; - readonly attachment: ChatFileAttachment; - } -) & { readonly sourceIdentifier?: string }; +export type { VideoPreviewSource } from "../lib/videoPreviewSource"; function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { const player = useVideoPlayer(props.file.uri, (player) => { @@ -120,7 +113,7 @@ function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { } function OpenVideoPreviewModal(props: { - readonly source: VideoPreviewSource; + readonly source: AttachmentVideoPreviewSource; readonly onRequestClose: () => void; }) { const { source } = props; @@ -250,6 +243,9 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource, props.onRequestClose]); const { source } = props; if (source === null || !isFocused) return null; + if (source.type === "media") { + return ; + } const key = source.type === "local" ? `local:${source.attachment.id}:${source.attachment.fileUri}` diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx index 0be94c700ce68..cfb8ceeb2aca2 100644 --- a/apps/mobile/src/components/VideoThumbnailImage.tsx +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -11,6 +11,7 @@ import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails export function VideoThumbnailImage(props: { readonly cacheKey: string; readonly source: string | DraftComposerFileAttachment | null; + readonly contentFit?: "cover" | "contain"; }) { const { cacheKey, source } = props; const isFocused = useIsFocused(); @@ -37,7 +38,7 @@ export function VideoThumbnailImage(props: { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 5dddac1dd8208..052c89a7e623e 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,7 +1,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { MenuAction } from "@react-native-menu/menu"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; @@ -11,6 +11,9 @@ import { type ProjectReadFileResult, ThreadId, } from "@t3tools/contracts"; +import { videoMimeType } from "@t3tools/shared/video"; +import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; @@ -24,6 +27,8 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { isPdfFile } from "../../lib/filePreview"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; +import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useEnvironmentQuery } from "../../state/query"; @@ -47,6 +52,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; +import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, @@ -54,8 +60,9 @@ import { isImagePreviewFile, isMarkdownPreviewFile, isSvgImagePreviewFile, + isVideoPreviewFile, } from "./filePath"; -import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl"; +import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl"; type FileViewMode = "preview" | "source"; @@ -84,7 +91,8 @@ function normalizeRouteLine(value: string | null): number | null { } function defaultViewMode(path: string | null): FileViewMode { - return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path)) + return path !== null && + (isBrowserPreviewFile(path) || isImagePreviewFile(path) || isVideoPreviewFile(path)) ? "preview" : "source"; } @@ -92,6 +100,10 @@ function defaultViewMode(path: string | null): FileViewMode { function FileContent(props: { readonly activeMode: FileViewMode; readonly previewUri: string | null; + readonly previewUnavailable: boolean; + readonly videoSource: MediaVideoPreviewSource | null; + readonly mediaSource?: MediaActionsSource; + readonly resolveVideoUri: () => Promise; readonly fileContents: string | null; readonly fileError: string | null; readonly relativePath: string; @@ -99,10 +111,25 @@ function FileContent(props: { readonly truncated: boolean; readonly onRefresh?: () => Promise | void; }) { + // Reopening a mutable host file must not reuse a poster from an earlier visit. + const thumbnailInstanceId = useId(); const isMarkdown = isMarkdownPreviewFile(props.relativePath); const isBrowserFile = isBrowserPreviewFile(props.relativePath); const isImageFile = isImagePreviewFile(props.relativePath); + if (isVideoPreviewFile(props.relativePath)) { + return ( + + ); + } + if (props.activeMode === "preview" && isImageFile) { if (isSvgImagePreviewFile(props.relativePath)) { return ; @@ -111,6 +138,7 @@ function FileContent(props: { ); } @@ -489,29 +517,73 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { readonly mode: FileViewMode; } | null>(null); const [previewRevision, setPreviewRevision] = useState(0); + const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]); const [fullScreenPreview, setFullScreenPreview] = useState(null); - const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); - const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); + const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath); + const isBrowserFile = relativePath !== null && !isVideoFile && isBrowserPreviewFile(relativePath); + const isImageFile = relativePath !== null && !isVideoFile && isImagePreviewFile(relativePath); const canPreview = - relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile); + relativePath !== null && + (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile || isVideoFile); const activeMode = relativePath !== null && modeOverride?.path === relativePath ? modeOverride.mode : defaultViewMode(relativePath); - const resolvedActiveMode = canPreview ? activeMode : "source"; - const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null; - const assetPreviewUri = useWorkspaceFileAssetUrl({ + const resolvedActiveMode = isVideoFile ? "preview" : canPreview ? activeMode : "source"; + const assetPreviewPath = isBrowserFile || isImageFile || isVideoFile ? relativePath : null; + const assetPreview = useWorkspaceFileAssetUrlState({ cwd, environmentId, relativePath: assetPreviewPath, threadId, }); + const assetPreviewUri = assetPreview._tag === "Success" ? assetPreview.url : null; + const mediaSource = useMemo( + () => + environmentId !== null && + threadId !== null && + relativePath !== null && + assetPreview.resource !== null && + "path" in assetPreview.resource && + typeof assetPreview.resource.path === "string" && + (isImageFile || isVideoFile) + ? { + reference: mediaFileReference(assetPreview.resource.path, cwd), + name: basename(relativePath), + mimeType: + mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) ?? + "application/octet-stream", + environmentId, + threadId, + resource: assetPreview.resource, + } + : undefined, + [assetPreview.resource, cwd, environmentId, isImageFile, isVideoFile, relativePath, threadId], + ); + const mediaActions = useMediaActions(mediaSource); + const videoSource = useMemo( + () => + environmentId !== null && + relativePath !== null && + assetPreview.resource?._tag === "media-file" + ? { + type: "media", + environmentId, + resource: assetPreview.resource, + name: basename(relativePath), + mimeType: videoMimeType({ name: relativePath, mimeType: "" }) ?? "video/mp4", + actionsSource: mediaSource, + } + : null, + [assetPreview.resource, environmentId, relativePath, mediaSource], + ); const previewUri = assetPreviewUri === null || previewRevision === 0 ? assetPreviewUri : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; const needsFileContents = relativePath !== null && + !isVideoFile && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); const fileQuery = useEnvironmentQuery( environmentId !== null && cwd !== null && relativePath !== null && needsFileContents @@ -562,7 +634,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const fileMenuActions = useMemo(() => { if (relativePath === null) return []; - const canToggleMode = canPreview && !isImageFile; + const canToggleMode = canPreview && !isImageFile && !isVideoFile; return [ canToggleMode ? ({ @@ -582,13 +654,26 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => setModeOverride({ path: relativePath, mode: "source" }), } as const) : null, - { - id: "copy-path", - title: "Copy path", - icon: "doc.on.doc", - inline: false, - onPress: () => copyTextWithHaptic(relativePath), - } as const, + ...(mediaSource + ? mediaActions.actions + .filter(({ id }) => id !== "open-file") + .map((action) => ({ + id: action.id, + title: action.title, + icon: + action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), + inline: false, + onPress: action.run, + })) + : [ + { + id: "copy-path", + title: "Copy path", + icon: "doc.on.doc", + inline: false, + onPress: () => copyTextWithHaptic(relativePath), + } as const, + ]), isPdfFile({ name: relativePath }) && previewUri !== null ? ({ id: "open-pdf", @@ -612,24 +697,31 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"), } as const) : null, - resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) + resolvedActiveMode === "preview" && (isBrowserFile || isImageFile || isVideoFile) ? ({ id: "refresh", title: "Refresh", icon: "arrow.clockwise", inline: false, - onPress: () => setPreviewRevision((current) => current + 1), + onPress: async () => { + if (isVideoFile) await assetPreview.refresh(); + setPreviewRevision((current) => current + 1); + }, } as const) : null, ].filter((action) => action !== null); }, [ assetPreviewUri, + assetPreview.refresh, previewUri, canPreview, isBrowserFile, isImageFile, + isVideoFile, relativePath, resolvedActiveMode, + mediaSource, + mediaActions.actions, ]); const androidFileMenuActions = useMemo( @@ -782,8 +874,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { (null); const [preview, setPreview] = useState(null); const sourceIdentifier = useId(); + const mediaActions = useMediaActions(props.actionsSource); const imageSource = useMemo( () => ({ uri: props.uri, cache: "force-cache" as const }), [props.uri], @@ -34,6 +38,7 @@ function ResolvedWorkspaceFileImagePreview(props: { uri: props.uri, name: props.accessibilityLabel, sourceIdentifier, + actionsSource: props.actionsSource, }) } > @@ -50,13 +55,16 @@ function ResolvedWorkspaceFileImagePreview(props: { /> - {loadError !== null ? ( ) : null} + + + + setPreview(null)} />
); @@ -65,6 +73,7 @@ function ResolvedWorkspaceFileImagePreview(props: { function CachedWorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string; + readonly actionsSource?: MediaActionsSource; }) { const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); const imageResult = useAtomValue(imageAtom); @@ -93,6 +102,7 @@ function CachedWorkspaceFileImagePreview(props: { ); } @@ -100,6 +110,7 @@ function CachedWorkspaceFileImagePreview(props: { export function WorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string | null; + readonly actionsSource?: MediaActionsSource; }) { if (props.uri === null) { return ( @@ -116,6 +127,7 @@ export function WorkspaceFileImagePreview(props: { ); } diff --git a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx new file mode 100644 index 0000000000000..aaa13427fac09 --- /dev/null +++ b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { View } from "react-native"; + +import { EmptyState } from "../../components/EmptyState"; +import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; + +/** Uses the signed progressive URL directly; choosing a file never preloads its video bytes as text. */ +export function WorkspaceFileVideoPreview(props: { + readonly name: string; + readonly thumbnailKey: string; + readonly uri: string | null; + readonly source: MediaVideoPreviewSource | null; + readonly resolvePlaybackUri: () => Promise; + readonly unavailable: boolean; +}) { + const [preview, setPreview] = useState(null); + const uri = props.uri; + + if (props.unavailable) { + return ( + + + + ); + } + + return ( + + setPreview(props.source) + } + /> + setPreview(null)} /> + + ); +} diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 385d5c139eeae..12217aab74f11 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -1,6 +1,7 @@ import { isWorkspaceBrowserPreviewPath, isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, } from "@t3tools/shared/filePreview"; export interface FileBreadcrumb { @@ -95,6 +96,10 @@ export function isImagePreviewFile(path: string): boolean { return isWorkspaceImagePreviewPath(path); } +export function isVideoPreviewFile(path: string): boolean { + return isWorkspaceVideoPreviewPath(path); +} + export function isSvgImagePreviewFile(path: string): boolean { return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? ""); } diff --git a/apps/mobile/src/features/files/preload-workspace-file.ts b/apps/mobile/src/features/files/preload-workspace-file.ts index b9e21cfd98f93..7df750883f52e 100644 --- a/apps/mobile/src/features/files/preload-workspace-file.ts +++ b/apps/mobile/src/features/files/preload-workspace-file.ts @@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; -import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath"; +import { isBrowserPreviewFile, isImagePreviewFile, isVideoPreviewFile } from "./filePath"; import { prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; @@ -25,7 +25,11 @@ export function preloadWorkspaceFileContents(input: { readonly relativePath: string; readonly theme: ReviewDiffTheme; }): void { - if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) { + if ( + isBrowserPreviewFile(input.relativePath) || + isImagePreviewFile(input.relativePath) || + isVideoPreviewFile(input.relativePath) + ) { return; } diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts index 70ea3e43582b5..eb2ba93b4979f 100644 --- a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -1,10 +1,10 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useMemo } from "react"; -import { useAssetUrl } from "../../state/assets"; -import { resolveWorkspaceFilePath } from "./filePath"; +import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; +import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; -export function useWorkspaceFileAssetUrl(props: { +export function useWorkspaceFileAssetUrlState(props: { readonly cwd: string | null; readonly environmentId: EnvironmentId | null; readonly relativePath: string | null; @@ -18,14 +18,18 @@ export function useWorkspaceFileAssetUrl(props: { [props.cwd, props.relativePath], ); - return useAssetUrl( - props.environmentId, - absolutePath !== null && props.threadId !== null - ? { - _tag: "workspace-file", - threadId: props.threadId, - path: absolutePath, - } - : null, + const resource = useMemo( + () => + absolutePath !== null && props.threadId !== null + ? { + _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file", + threadId: props.threadId, + path: absolutePath, + } + : null, + [absolutePath, props.threadId], ); + const state = useAssetUrlState(props.environmentId, resource); + const refresh = useRefreshAssetUrl(props.environmentId, resource); + return { ...state, resource, refresh }; } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index cb72aa1f653e7..3cbf02efa2ba9 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,6 +1,6 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; -import { type LegendListRef } from "@legendapp/list/react-native"; +import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import type { AssetResource, ChatAttachment, @@ -33,6 +33,7 @@ import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { + createContext, memo, useCallback, useContext, @@ -97,6 +98,15 @@ import { import { AppText as Text } from "../../components/AppText"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; +import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; +import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; +import { MediaActionsMenu } from "../../components/MediaActionsMenu"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type MediaVideoPreviewSource, +} from "../../lib/videoPreviewSource"; import { CopyTextButton } from "../../components/CopyTextButton"; import { parseReviewCommentMessageSegments, @@ -123,7 +133,11 @@ import { import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; -import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; +import { + normalizeNativeMarkdownUrl, + resolveMarkdownInlineCodePresentation, + resolveMarkdownLinkPresentation, +} from "@t3tools/mobile-markdown-text/links"; import { deriveThreadFeedPresentation, type ThreadFeedEntry, @@ -143,7 +157,12 @@ import { WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets"; +import { + assetEnvironment, + useAssetUrl, + useAssetUrlState, + useRefreshAssetUrl, +} from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; import * as Option from "effect/Option"; @@ -272,6 +291,7 @@ function MessageAttachmentFile(props: { }) { const sourceIdentifier = useId(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); @@ -453,9 +473,11 @@ function ThreadMarkdownImageView(props: { readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; + readonly actionsSource?: MediaActionsSource; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); + const mediaActions = useMediaActions(props.actionsSource); const [availableWidth, setAvailableWidth] = useState(0); const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); @@ -498,36 +520,51 @@ function ThreadMarkdownImageView(props: { ) : ( )} + {props.actionsSource ? ( + + + + ) : null} ) : ( - - props.onPressPreview({ - kind: "image", - uri: props.uri!, - name: props.alt ?? "Image", - sourceIdentifier, - }) - } - style={{ alignSelf: "flex-start" }} - > - - setFailedUri(props.uri)} - /> - - + + + + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.alt ?? "Image", + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + style={{ alignSelf: "flex-start" }} + > + + setFailedUri(props.uri)} + /> + + + + {props.actionsSource ? ( + + + + ) : null} + )} {props.alt ? ( @@ -574,9 +611,10 @@ function ThreadMarkdownImageRequest(props: { /** Environment-hosted image that loads through a signed asset URL. */ function ThreadMarkdownImage(props: { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract; readonly alt: string | null; readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); @@ -591,11 +629,59 @@ function ThreadMarkdownImage(props: { } unavailable={assetUrl._tag === "Failure"} alt={props.alt} + actionsSource={props.actionsSource} onPressPreview={props.onPressPreview} /> ); } +const ThreadMediaVisibleContext = createContext(false); +// LegendList only computes hook visibility when the list has a viewability config. +const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; + +function ThreadMediaVisibility(props: { readonly children: ReactNode }) { + const [visible, setVisible] = useState(false); + useViewabilityAmount( + useCallback((token) => setVisible(token.sizeVisible > 0), []), + ); + return {props.children}; +} + +function ThreadMarkdownVideo(props: { + readonly source: MediaVideoPreviewSource; + readonly onExpand: (source: MediaVideoPreviewSource) => void; +}) { + const { source } = props; + const visible = useContext(ThreadMediaVisibleContext); + const thumbnailKey = mediaVideoThumbnailKey(source); + const asset = useAssetUrlState( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const refreshAssetUrl = useRefreshAssetUrl( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + return ( + mediaVideoPreviewUri(source, await refreshAssetUrl()) + : undefined + } + name={source.name} + thumbnailKey={thumbnailKey} + thumbnailVisible={visible} + unavailable={"resource" in source && asset._tag === "Failure"} + actionsSource={source.actionsSource} + onExpand={() => props.onExpand(source)} + /> + ); +} + function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { return ( (); +const MarkdownLinkLabelContext = createContext(false); const markdownLinkStyles = StyleSheet.create({ inlineIcon: { width: 14, @@ -653,15 +740,14 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly color: string; readonly host: string; readonly href: string; + readonly onPress: (href: string) => void; }) { const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); return ( { - void tryOpenExternalUrl(props.href, "markdown-link"); - }} + onPress={() => props.onPress(props.href)} style={{ color: props.color, textDecorationLine: "none", @@ -686,6 +772,37 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { ); }); +function MarkdownInlineCode(props: { + readonly content: string; + readonly textColor: string; + readonly codeColor: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly onLinkPress: (href: string) => void; +}) { + const insideLink = useContext(MarkdownLinkLabelContext); + const presentation = insideLink ? null : resolveMarkdownInlineCodePresentation(props.content); + return ( + props.onLinkPress(presentation.href) : undefined} + style={{ + color: presentation ? props.textColor : props.codeColor, + fontSize: props.fontSize, + lineHeight: props.lineHeight, + }} + > + {presentation ? ( + + ) : null} + {presentation?.label ?? props.content} + + ); +} + const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< CodexArtifactTemplate["artifactKind"], AppSymbolName @@ -1078,30 +1195,35 @@ function useMarkdownStyles( } if (presentation.kind === "external") { return ( - - {children} - + + + {children} + + ); } const linkHref = presentation.href; return ( - { - void tryOpenExternalUrl(linkHref, "markdown-link"); - } - : undefined - } - style={{ color: markdownLinkColor }} - > - {children} - + + { + void tryOpenExternalUrl(linkHref, "markdown-link"); + } + : undefined + } + style={{ color: markdownLinkColor }} + > + {children} + + ); }, list: ({ node, Renderer, ordered = false, start = 1 }) => ( @@ -1144,21 +1266,16 @@ function useMarkdownStyles( title: node.title ?? null, }) ?? undefined) : undefined, - code_inline: ({ content }) => { - const value = content ?? ""; - return ( - - {value} - - ); - }, + code_inline: ({ content }) => ( + + ), ...(preserveSoftBreaks ? { soft_break: () => {"\n"}, @@ -1954,11 +2071,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { path: relativePath.split("/").filter((segment) => segment.length > 0), ...(presentation.line ? { line: String(presentation.line) } : {}), }); + return; + } + } + + const media = resolveMarkdownMediaPreview(href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + }); + if (media) { + void Haptics.selectionAsync(); + if (media.kind === "video") { + setExpandedVideo((current) => current ?? media.source); + } else { + setExpandedFile((current) => current ?? media.source); } return; } - if (presentation.href) { + if (presentation.kind !== "file" && presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { setExpandedFile( (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, @@ -1972,14 +2104,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const renderMarkdownImage = useCallback( (image) => { + const media = resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed: true, + }); + if (media?.kind === "video") { + return ( + setExpandedVideo((current) => current ?? source)} + /> + ); + } const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); if (imageSource._tag === "Direct") { return ( setExpandedFile((current) => current ?? source)} /> ); @@ -1991,12 +2139,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedFile((current) => current ?? source)} /> ); @@ -2009,12 +2158,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { threadId: props.threadId, workspaceRoot: props.workspaceRoot, }); + const media = viewedImage + ? resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed: true, + }) + : null; + const actionsSource = media?.source.actionsSource; return viewedImage ? ( setExpandedFile((current) => current ?? source)} /> ) : null; @@ -2438,30 +2601,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { key={info.item.id} entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined} > - {renderFeedEntry(info, { - environmentId: props.environmentId, - copiedRowId, - expandedWorkRows, - terminalAssistantMessageIds, - unsettledTurnId, - onCopyWorkRow, - onToggleWorkGroup, - onToggleWorkRow, - onToggleTurnFold, - onPressPreview, - onPressVideo, - onMarkdownLinkPress, - renderMarkdownImage, - renderViewedImage, - iconSubtleColor, - userBubbleColor, - markdownStyles, - reviewCommentColors, - reviewCommentBubbleWidth, - userBubbleMaxWidth, - skills: props.skills, - onUseArtifactTemplate: props.onUseArtifactTemplate, - })} + + {renderFeedEntry(info, { + environmentId: props.environmentId, + copiedRowId, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, + onCopyWorkRow, + onToggleWorkGroup, + onToggleWorkRow, + onToggleTurnFold, + onPressPreview, + onPressVideo, + onMarkdownLinkPress, + renderMarkdownImage, + renderViewedImage, + iconSubtleColor, + userBubbleColor, + markdownStyles, + reviewCommentColors, + reviewCommentBubbleWidth, + userBubbleMaxWidth, + skills: props.skills, + onUseArtifactTemplate: props.onUseArtifactTemplate, + })} +
), [ @@ -2586,6 +2751,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { data={presentedFeed} extraData={listAppearanceData} renderItem={renderItem} + viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} keyExtractor={(entry) => entry.id} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index 49a8b46648e1d..bf3d009b74abb 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -3,6 +3,22 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; describe("resolveMarkdownLinkPresentation", () => { + it("treats protocol-relative media as an external URL, not a filesystem path", () => { + expect(resolveMarkdownLinkPresentation("//cdn.example.com/clip.mp4?sig=a%2fb#t=2")).toEqual({ + kind: "external", + href: "https://cdn.example.com/clip.mp4?sig=a%2fb#t=2", + host: "cdn.example.com", + }); + }); + + it("separates encoded filename characters from a video playback fragment", () => { + expect(resolveMarkdownLinkPresentation("/tmp/clip%23one.mp4#t=2")).toMatchObject({ + path: "/tmp/clip#one.mp4", + label: "clip#one.mp4", + icon: "video", + }); + }); + it("extracts external link hosts", () => { expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ kind: "external", @@ -11,15 +27,16 @@ describe("resolveMarkdownLinkPresentation", () => { }); }); - it("renders file URLs as basename pills with positions", () => { - expect( - resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), - ).toEqual({ + it.each([ + ["file:///Users/julius/project/src/main.ts#L42C7", "/Users/julius/project/src/main.ts"], + ["file://server/share/src/main.ts#L42C7", "\\\\server\\share\\src\\main.ts"], + ])("preserves the file URL path and position for %s", (href, path) => { + expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "file", - href: "file:///Users/julius/project/src/main.ts#L42C7", + href, icon: "typescript", label: "main.ts:42:7", - path: "/Users/julius/project/src/main.ts", + path, line: 42, column: 7, }); diff --git a/apps/mobile/src/lib/markdownMedia.test.ts b/apps/mobile/src/lib/markdownMedia.test.ts new file mode 100644 index 0000000000000..77834630dc2f5 --- /dev/null +++ b/apps/mobile/src/lib/markdownMedia.test.ts @@ -0,0 +1,77 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownMediaPreview } from "./markdownMedia"; + +const input = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + workspaceRoot: "/repo", +}; + +describe("resolveMarkdownMediaPreview", () => { + it("decodes remote filenames once without changing the authored URL", () => { + const href = "https://cdn.example.com/clip%20one%2520%2Emp4?signature=a%2fb#t=2"; + expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ + kind: "video", + source: { + uri: href, + actionsSource: { + name: "clip one%20.mp4", + mimeType: "video/mp4", + reference: { kind: "url", url: href }, + }, + }, + }); + }); + + it("provides extensionless image actions only for image embeds", () => { + const href = "https://cdn.example.com/render?id=42"; + expect(resolveMarkdownMediaPreview(href, input)).toBeNull(); + expect(resolveMarkdownMediaPreview(href, { ...input, imageEmbed: true })).toMatchObject({ + kind: "image", + source: { actionsSource: { reference: { kind: "url", url: href }, mimeType: "image/*" } }, + }); + }); + + it.each([ + ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"], + ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"], + ["/tmp/frame%2523one.png:12", "/tmp/frame%23one.png"], + ["file://server/share/frame.png", "\\\\server\\share\\frame.png"], + ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png"], + ])("keeps encoded filename and UNC semantics for %s", (href, path) => { + expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ + kind: "image", + source: { + resource: { path }, + actionsSource: { reference: { kind: "file", path } }, + }, + }); + }); + + it("separates a video playback fragment from literal filename characters", () => { + expect(resolveMarkdownMediaPreview("/tmp/clip%23one.mp4#t=2", input)).toMatchObject({ + kind: "video", + source: { + srcFragment: "#t=2", + resource: { path: "/tmp/clip#one.mp4" }, + actionsSource: { reference: { kind: "file", path: "/tmp/clip#one.mp4" } }, + }, + }); + }); + + it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { + expect( + resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), + ).toMatchObject({ + kind: "video", + source: { + uri: "https://cdn.example.com/clip.mp4?signature=a%2fb#t=2", + actionsSource: { + reference: { kind: "url", url: "//cdn.example.com/clip.mp4?signature=a%2fb#t=2" }, + }, + }, + }); + }); +}); diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts new file mode 100644 index 0000000000000..2196c2f22f2b1 --- /dev/null +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -0,0 +1,89 @@ +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { + mediaFileReference, + mediaReferenceFileName, + mediaUrlReference, +} from "@t3tools/client-runtime/media-reference"; + +import type { FilePreviewSource } from "../components/FilePreviewModal"; +import type { MediaVideoPreviewSource } from "./videoPreviewSource"; +import type { MediaActionsSource } from "./mediaActions"; + +/** Resolves only explicit media references. Ordinary links keep their existing navigation. */ +export function resolveMarkdownMediaPreview( + href: string, + input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly workspaceRoot: string | null | undefined; + /** Image syntax can target an endpoint without a recognizable extension. */ + readonly imageEmbed?: boolean; + }, +): + | { readonly kind: "image"; readonly source: FilePreviewSource } + | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } + | null { + const classified = classifyMarkdownImageSource(href, input.workspaceRoot); + if (classified._tag === "Blocked") return null; + const path = + classified._tag === "WorkspaceFile" + ? classified.path.replace(/:\d+(?::\d+)?$/, "") + : classified.uri.split(/[?#]/, 1)[0]!; + const basename = path.split(/[\\/]/).at(-1) ?? ""; + const extensionIndex = basename.lastIndexOf("."); + // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. + const detectedMimeType = + classified._tag === "Direct" + ? mediaMimeType(classified.uri) + : extensionIndex < 0 + ? null + : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); + const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); + if (mimeType === null) return null; + const kind = mimeType.startsWith("video/") ? "video" : "image"; + const reference = + classified._tag === "Direct" + ? mediaUrlReference(classified.uri) + : mediaFileReference(path, input.workspaceRoot); + const name = + (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image"); + const srcFragment = markdownImageSourceFragment(href); + const target = + classified._tag === "Direct" + ? { uri: normalizeNativeMarkdownUrl(classified.uri) } + : { + environmentId: input.environmentId, + resource: { + _tag: "media-file" as const, + threadId: input.threadId, + path, + }, + ...(srcFragment ? { srcFragment } : {}), + }; + const actionsSource: MediaActionsSource = + classified._tag === "Direct" + ? { reference, uri: classified.uri, name, mimeType } + : { + reference, + environmentId: input.environmentId, + threadId: input.threadId, + resource: { _tag: "media-file", threadId: input.threadId, path }, + name, + mimeType, + }; + return kind === "video" + ? { + kind, + source: { type: "media", name, mimeType, ...target, actionsSource }, + } + : { + kind, + source: { kind, name, ...target, actionsSource }, + }; +} diff --git a/apps/mobile/src/lib/mediaActions.ts b/apps/mobile/src/lib/mediaActions.ts new file mode 100644 index 0000000000000..14dc2de7bd212 --- /dev/null +++ b/apps/mobile/src/lib/mediaActions.ts @@ -0,0 +1,121 @@ +import { useNavigation } from "@react-navigation/native"; +import type { MediaReference } from "@t3tools/client-runtime/media-reference"; +import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import { useEffect, useRef, useState } from "react"; +import { Alert } from "react-native"; + +import { useRefreshAssetUrl } from "../state/assets"; +import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; +import { copyTextWithHaptic } from "./copyTextWithHaptic"; + +/** Authored source metadata is kept separate from temporary preview/download URLs. */ +export type MediaActionsSource = { + readonly reference?: MediaReference; + readonly name: string; + readonly mimeType: string; +} & ( + | { readonly uri: string } + | { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly resource: AssetResource; + } +); + +export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) { + const navigation = useNavigation(); + const refresh = useRefreshAssetUrl( + source && "environmentId" in source ? source.environmentId : null, + source && "resource" in source ? source.resource : null, + ); + const controller = useRef(null); + const [sharing, setSharing] = useState(false); + useEffect(() => () => controller.current?.abort(), []); + + const share = () => { + if (!source || controller.current) return; + const request = new AbortController(); + controller.current = request; + setSharing(true); + void (async () => { + const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); + if (request.signal.aborted) return; + if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); + const input = { + attachment: { name: source.name, mimeType: source.mimeType }, + signal: request.signal, + }; + if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); + else await downloadAndShareAttachment({ ...input, url: uri }); + })() + .catch((error: unknown) => { + if (!request.signal.aborted) { + Alert.alert( + "Could not share file", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (controller.current === request) { + controller.current = null; + if (!request.signal.aborted) setSharing(false); + } + }); + }; + + const reference = source?.reference; + const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source + ? [ + ...(reference?.kind === "file" + ? [ + { + id: "copy-path", + title: "Copy full path", + run: () => copyTextWithHaptic(reference.path), + }, + ...(reference.relativePath + ? [ + { + id: "copy-relative-path", + title: "Copy relative path", + run: () => copyTextWithHaptic(reference.relativePath!), + }, + ] + : []), + ...(reference.relativePath && source && "environmentId" in source + ? [ + { + id: "open-file", + title: "Open in file viewer", + run: () => { + onOpenFile?.(); + navigation.navigate("ThreadFile", { + environmentId: String(source.environmentId), + threadId: String(source.threadId), + path: reference.relativePath!.split("/"), + }); + }, + }, + ] + : []), + ] + : reference + ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }] + : []), + { + id: "share", + title: sharing ? "Opening share sheet…" : "Save or share", + run: share, + disabled: sharing, + }, + ] + : []; + return { + title: reference?.kind === "file" ? reference.path : reference?.url, + actions, + sharing, + share, + }; +} diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 867d9e983017d..1e7cb5f3164e2 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -11,6 +11,43 @@ import { } from "@t3tools/mobile-markdown-text/markdown"; describe("nativeMarkdownTextRuns", () => { + it("links a path-shaped code span without changing the same path in prose", () => { + expect( + nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { type: "text", content: "/tmp/frame.png " }, + { type: "code_inline", content: "/tmp/frame.png" }, + ], + }), + ).toEqual([ + { text: "/tmp/frame.png " }, + { text: "frame.png", href: "/tmp/frame.png", fileIcon: "image" }, + ]); + }); + + it("preserves the destination of a link with a code-formatted label", () => { + expect( + nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { + type: "link", + href: "https://example.com/docs", + children: [{ type: "code_inline", content: "src/main.ts" }], + }, + ], + }), + ).toEqual([ + { + text: "src/main.ts", + code: true, + href: "https://example.com/docs", + externalHost: "example.com", + }, + ]); + }); + it("preserves inline emphasis and code styles", () => { const node: MarkdownNode = { type: "paragraph", diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts new file mode 100644 index 0000000000000..af87a8d0f73ac --- /dev/null +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -0,0 +1,54 @@ +import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; + +import type { DraftComposerFileAttachment } from "./composerImages"; +import type { MediaActionsSource } from "./mediaActions"; + +export type MediaVideoPreviewSource = { + readonly type: "media"; + readonly name: string; + readonly mimeType: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} & ( + | { readonly uri: string } + | { + readonly environmentId: EnvironmentId; + readonly resource: Extract; + } +); + +/** Resolves the current capability without making it the identity of the video. */ +export function mediaVideoPreviewUri( + source: MediaVideoPreviewSource, + assetUrl: string | null, +): string | null { + if ("uri" in source) return source.uri; + return assetUrl === null ? null : assetUrl + (source.srcFragment ?? ""); +} + +/** Keeps thumbnails independent of refreshed asset signatures and scoped to their environment. */ +export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string { + return JSON.stringify( + "uri" in source + ? ["media-video", source.uri] + : [ + "media-video", + source.environmentId, + source.resource.threadId, + source.resource.path, + source.srcFragment ?? "", + ], + ); +} + +export type AttachmentVideoPreviewSource = ( + | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } + | { + readonly type: "remote"; + readonly environmentId: EnvironmentId; + readonly attachment: ChatFileAttachment; + } +) & { readonly sourceIdentifier?: string }; + +export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource; diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 611a1ed8b99b0..9e3e43c7cdcb3 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -2,9 +2,11 @@ import { useAtomValue } from "@effect/atom-react"; import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; +import { useAtomQueryRunner } from "./use-atom-query-runner"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); @@ -44,3 +46,23 @@ export function useAssetUrl( const state = useAssetUrlState(environmentId, resource); return state._tag === "Success" ? state.url : null; } + +/** Explicit playback and sharing must reauthorize files that may have been replaced on disk. */ +export function useRefreshAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): () => Promise { + const connection = usePreparedConnection(environmentId); + const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, + reportFailure: false, + }); + return useCallback(async () => { + if (environmentId === null || resource === null || httpBaseUrl === null) return null; + const result = await createUrl({ environmentId, input: { resource } }); + return result._tag === "Success" + ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) + : null; + }, [createUrl, environmentId, httpBaseUrl, resource]); +} diff --git a/apps/mobile/src/state/use-atom-query-runner.ts b/apps/mobile/src/state/use-atom-query-runner.ts index 22f971e09a5db..691b1f43cb87b 100644 --- a/apps/mobile/src/state/use-atom-query-runner.ts +++ b/apps/mobile/src/state/use-atom-query-runner.ts @@ -1,7 +1,7 @@ import { RegistryContext } from "@effect/atom-react"; import { executeAtomQuery, - type AtomCommandOptions, + type AtomQueryOptions, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult, type Atom } from "effect/unstable/reactivity"; @@ -9,12 +9,13 @@ import { useCallback, useContext } from "react"; export function useAtomQueryRunner( family: (target: T) => Atom.Atom>, - options?: string | AtomCommandOptions, + options?: string | AtomQueryOptions, ): (target: T) => Promise> { const registry = useContext(RegistryContext); const explicitLabel = typeof options === "string" ? options : options?.label; const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + const refresh = typeof options === "string" ? false : (options?.refresh ?? false); return useCallback( (target: T) => { @@ -23,8 +24,9 @@ export function useAtomQueryRunner( label: explicitLabel ?? atom.label?.[0] ?? "atom query", reportFailure, reportDefect, + refresh, }); }, - [explicitLabel, family, registry, reportDefect, reportFailure], + [explicitLabel, family, registry, refresh, reportDefect, reportFailure], ); } diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 8cf9c642384c6..4a47a17fabc3d 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,4 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary. import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeFSP from "node:fs/promises"; import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; @@ -9,18 +12,28 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as TestClock from "effect/testing/TestClock"; +import { HttpServerResponse } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +import { openMediaFile } from "./MediaFile.ts"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) }; +}); const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); const testLayer = Layer.mergeAll( + NodeHttpPlatform.layer, configLayer, WorkspacePaths.layer, ProjectFaviconResolver.layer.pipe( @@ -31,6 +44,328 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("issues exact URLs for images and videos outside the workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-root-" }); + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-outside-" }); + for (const [name, mimeType] of [ + ["screenshot.png", "image/png"], + ["recording.mp4", "video/mp4"], + ["recording.webm", "video/webm"], + ] as const) { + const filePath = path.join(outside, name); + yield* fs.writeFileString(filePath, "media"); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + expect(yield* resolveAsset(token, suffix.slice(separator + 1))).toMatchObject({ + kind: "file", + path: canonicalFile, + mimeType, + }); + yield* fs.writeFileString(path.join(outside, "sibling.png"), "private sibling"); + expect(yield* resolveAsset(token, "sibling.png")).toBeNull(); + expect(yield* resolveAsset(token, `../${name}`)).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull(); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("resolves relative media paths from the thread workspace, including outside it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-relative-" }); + const root = path.join(directory, "workspace"); + yield* fs.makeDirectory(root); + for (const relativePath of ["screenshot.png", "../recording.mp4"]) { + const filePath = path.resolve(root, relativePath); + yield* fs.writeFileString(filePath, "media"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: relativePath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)), + ).toMatchObject({ + kind: "file", + path: yield* fs.realPath(filePath), + }); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects non-media files, disguised targets, and directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" }); + for (const name of ["report.html", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { + const filePath = path.join(root, name); + yield* fs.writeFileString(filePath, "not media"); + const error = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + } + const disguisedPath = path.join(root, "disguised.png"); + yield* fs.symlink(path.join(root, "report.html"), disguisedPath); + const disguisedError = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath }, + }).pipe(Effect.flip); + expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError); + const directoryPath = path.join(root, "directory.png"); + yield* fs.makeDirectory(directoryPath); + const directoryError = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: directoryPath }, + }).pipe(Effect.flip); + expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("binds media URLs to the canonical target and rejects symlink substitution", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" }); + const filePath = path.join(root, "actual.svg"); + const aliasPath = path.join(root, "alias.png"); + const replacementPath = path.join(root, "other.svg"); + yield* fs.writeFileString(filePath, ""); + yield* fs.writeFileString(replacementPath, "private"); + yield* fs.symlink(filePath, aliasPath); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" }; + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(aliasPath); + yield* fs.symlink(replacementPath, aliasPath); + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(filePath); + yield* fs.symlink(replacementPath, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps full and partial responses bound to the file opened during resolution", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" }); + const filePath = path.join(root, "recording.mp4"); + const savedPath = path.join(root, "saved.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "0123456789"); + yield* fs.writeFileString(secretPath, "private information"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + for (const [range, expected, status] of [ + [undefined, "0123456789", 200], + ["bytes=2-5", "2345", 206], + ] as const) { + const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); + if (!asset) throw new Error("Expected a resolved media file"); + + yield* fs.rename(filePath, savedPath); + yield* fs.symlink(secretPath, filePath); + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range)); + expect(response.status).toBe(status); + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + yield* fs.remove(filePath); + yield* fs.rename(savedPath, filePath); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a symlink swapped in after canonical validation but before open", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + stat: Effect.fn(function* (requestedPath) { + const info = yield* fs.stat(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.remove(filePath); + yield* fs.symlink(secretPath, filePath); + } + return info; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("closes a descriptor rejected when its path changes during open", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const originalOpen = (yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + )).open; + let opened: NodeFSP.FileHandle | undefined; + const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => { + const handle = await originalOpen(target, flags, mode); + if (target === canonicalPath) { + opened = handle; + await NodeFSP.unlink(filePath); + await NodeFSP.symlink(secretPath, filePath); + } + return handle; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => openSpy.mockImplementation(originalOpen))); + expect(yield* openMediaFile(canonicalPath)).toBeNull(); + expect(opened).toBeDefined(); + expect(opened?.fd).toBe(-1); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects an ancestor symlink race even when canonical path rechecks would pass", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" }); + const publicDirectory = path.join(root, "public"); + const privateDirectory = path.join(root, "private"); + yield* fs.makeDirectory(publicDirectory); + yield* fs.makeDirectory(privateDirectory); + const filePath = path.join(publicDirectory, "recording.mp4"); + yield* fs.writeFileString(filePath, "public video"); + yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const native = yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + ); + const savedDirectory = path.join(root, "saved"); + const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => { + // A pathname-only guard can see the original parents during realpath, + // but the private file during both lstat calls and open. + await native.unlink(publicDirectory); + await native.rename(savedDirectory, publicDirectory); + const canonical = await native.realpath(canonicalPath); + await native.rename(publicDirectory, savedDirectory); + await native.symlink(privateDirectory, publicDirectory, "junction"); + return canonical; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)), + ); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + realPath: Effect.fn(function* (requestedPath) { + const canonical = yield* fs.realPath(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.rename(publicDirectory, savedDirectory); + yield* Effect.promise(() => + NodeFSP.symlink(privateDirectory, publicDirectory, "junction"), + ); + } + return canonical; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-replacement-" }); + const filePath = path.join(root, "recording.mp4"); + yield* fs.writeFileString(filePath, "original"); + const input = { + resource: { + _tag: "media-file" as const, + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + }; + const original = yield* issueAssetUrl(input); + const suffix = original.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + yield* fs.writeFileString(filePath, "in-place edit"); + const edited = yield* resolveAsset(token, name); + if (!edited) throw new Error("Expected the edited media file"); + const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited)); + expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit"); + + const replacement = path.join(root, "replacement.mp4"); + yield* fs.writeFileString(replacement, "replacement"); + yield* fs.rename(replacement, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + + const renewed = yield* issueAssetUrl(input); + const renewedSuffix = renewed.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const renewedSeparator = renewedSuffix.indexOf("/"); + const renewedAsset = yield* resolveAsset( + renewedSuffix.slice(0, renewedSeparator), + renewedSuffix.slice(renewedSeparator + 1), + ); + if (!renewedAsset) throw new Error("Expected the replacement media file"); + const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset)); + expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement"); + yield* fs.remove(filePath); + expect( + yield* resolveAsset( + renewedSuffix.slice(0, renewedSeparator), + renewedSuffix.slice(renewedSeparator + 1), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index d064ec07529a3..05801acca88f3 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -16,6 +16,7 @@ import { import { isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, + mediaMimeTypeFromExtension, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; @@ -41,6 +42,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -76,6 +78,14 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.String, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("media-file-exact"), + filePath: Schema.String, + device: Schema.String, + inode: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment"), @@ -115,6 +125,7 @@ export type ResolvedAsset = { readonly download?: boolean; readonly fileName?: string; readonly mimeType?: string; + readonly file?: OpenMediaFile; }; function decodeClaims(encodedPayload: string): AssetClaims | null { @@ -211,6 +222,55 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let sourcePath: string | undefined; switch (input.resource._tag) { + case "media-file": { + let requestedPath = input.resource.path; + if (!path.isAbsolute(requestedPath)) { + if (!input.workspaceRoot) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.workspaceRoot) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceRootNormalizationError({ resource: input.resource, cause }), + ), + ); + requestedPath = path.resolve(workspaceRoot, requestedPath); + } + const canonicalFile = yield* resolveCanonicalFile(requestedPath).pipe( + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!canonicalFile) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } + const identity = yield* openMediaFile(canonicalFile).pipe( + Effect.map((file) => + file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null, + ), + Effect.scoped, + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!identity) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + claims = { + version: 1, + kind: "media-file-exact", + filePath: canonicalFile, + ...identity, + expiresAt, + }; + fileName = path.basename(canonicalFile); + break; + } case "workspace-file": { if (!input.workspaceRoot) { return yield* new AssetWorkspaceContextNotFoundError({ @@ -526,6 +586,30 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; + if (claims.kind === "media-file-exact") { + if (decodedPath !== path.basename(claims.filePath)) return null; + const canonicalFile = yield* resolveCanonicalFile(claims.filePath).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical media path.", { + filePath: claims.filePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + if (canonicalFile !== claims.filePath) return null; + const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile)); + if (!mimeType) return null; + const file = yield* openMediaFile(canonicalFile, claims).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to open canonical media file.", { filePath: canonicalFile, cause }), + ), + Effect.orElseSucceed(() => null), + ); + return file + ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset) + : null; + } if (claims.kind === "workspace-file-exact") { if (decodedPath !== path.basename(claims.relativePath)) return null; const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ diff --git a/apps/server/src/assets/MediaFile.ts b/apps/server/src/assets/MediaFile.ts new file mode 100644 index 0000000000000..f1b63bb659e35 --- /dev/null +++ b/apps/server/src/assets/MediaFile.ts @@ -0,0 +1,113 @@ +// @effect-diagnostics nodeBuiltinImport:off - FileSystem does not expose no-follow +// or non-blocking open flags, and the response must keep the validated descriptor. +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; + +import * as NodeStream from "@effect/platform-node/NodeStream"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +class MediaFileOpenError extends Schema.TaggedErrorClass()( + "MediaFileOpenError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to open media file '${this.path}'.`; + } +} + +class MediaFileStatError extends Schema.TaggedErrorClass()( + "MediaFileStatError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read metadata for media file '${this.path}'.`; + } +} + +/** Holds the file identity and descriptor for one HTTP request, never a copy of its bytes. */ +export interface OpenMediaFile { + readonly handle: NodeFSP.FileHandle; + readonly info: NodeFS.BigIntStats; +} + +/** Opens a canonical media path once. Replacements cannot change the response's source. */ +export const openMediaFile = Effect.fn("openMediaFile")(function* ( + filePath: string, + identity?: { readonly device: string; readonly inode: string }, +) { + return yield* Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + const before = await NodeFSP.lstat(filePath, { bigint: true }); + if (!before.isFile() || before.ino === 0n) return null; + if ( + identity && + (before.dev.toString() !== identity.device || before.ino.toString() !== identity.inode) + ) { + return null; + } + + // Windows lacks these flags; the descriptor/path identity checks still apply. + const handle = await NodeFSP.open( + filePath, + NodeFS.constants.O_RDONLY | + (NodeFS.constants.O_NOFOLLOW ?? 0) | + (NodeFS.constants.O_NONBLOCK ?? 0), + ); + let accepted = false; + try { + const info = await handle.stat({ bigint: true }); + if (!info.isFile() || info.dev !== before.dev || info.ino !== before.ino) return null; + if ( + identity && + (info.dev.toString() !== identity.device || info.ino.toString() !== identity.inode) + ) { + return null; + } + if ((await NodeFSP.realpath(filePath)) !== filePath) return null; + const after = await NodeFSP.lstat(filePath, { bigint: true }); + if (!after.isFile() || info.dev !== after.dev || info.ino !== after.ino) return null; + accepted = true; + return { handle, info } satisfies OpenMediaFile; + } finally { + if (!accepted) await handle.close(); + } + }, + catch: (cause) => new MediaFileOpenError({ path: filePath, cause }), + }), + (file) => (file ? Effect.promise(() => file.handle.close()) : Effect.void), + ); +}); + +export const statMediaFile = Effect.fn("statMediaFile")(function* ( + filePath: string, + file: OpenMediaFile, +) { + return yield* Effect.tryPromise({ + try: () => file.handle.stat({ bigint: true }), + catch: (cause) => new MediaFileStatError({ path: filePath, cause }), + }); +}); + +export const streamMediaFile = (file: OpenMediaFile, offset: bigint, bytesToRead: bigint) => { + const start = Number(offset); + const end = Number(offset + bytesToRead - 1n); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) { + return null; + } + return NodeStream.fromReadable({ + evaluate: () => + file.handle.createReadStream({ + autoClose: false, + start, + end, + }), + }); +}; diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 7ae036bdc99e5..9d54adef8eda7 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "@effect/vitest"; -import { describe } from "vite-plus/test"; +import { describe, vi } from "vite-plus/test"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -7,6 +7,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { HttpServerResponse } from "effect/unstable/http"; +import { openMediaFile } from "./assets/MediaFile.ts"; import { assetResponseHeaders, @@ -19,6 +20,179 @@ import { const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); describe("video asset byte ranges", () => { + it.effect("uses current descriptor metadata after an in-place truncate or extension", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-current-stat-" }); + const filePath = path.join(directory, "clip.mp4"); + for (const [contents, range, method, expected, status, contentRange] of [ + ["1234", undefined, "GET", "1234", 200, null], + ["0123456789abcdef", undefined, "GET", "0123456789abcdef", 200, null], + ["1234", "bytes=4-", "GET", "", 416, "bytes */4"], + ["1234", "bytes=1-20", "GET", "234", 206, "bytes 1-3/4"], + ["0123456789abcdef", "bytes=10-", "GET", "abcdef", 206, "bytes 10-15/16"], + ["0123456789abcdef", undefined, "HEAD", "", 200, null], + ["", undefined, "GET", "", 200, null], + ["", "bytes=0-1", "GET", "", 416, "bytes */0"], + ] as const) { + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + yield* fs.writeFileString(filePath, contents); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + range, + undefined, + method, + ), + ); + expect(response.status).toBe(status); + expect(response.headers.get("content-range")).toBe(contentRange); + if (status !== 416) { + expect(response.headers.get("content-length")).toBe( + String(method === "HEAD" ? contents.length : expected.length), + ); + } + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect( + "rejects unaddressable ranges before streaming and preserves small ranges on large files", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-offset-limit-" }); + const filePath = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + const unsafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + const size = unsafeOffset + 32n; + for (const [range, status] of [ + [`bytes=${unsafeOffset}-${unsafeOffset}`, 416], + [`bytes=0-${unsafeOffset}`, 416], + ["bytes=-1", 416], + [undefined, 413], + ["bytes=0-1", 206], + ] as const) { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + // Model a sparse file beyond the native stream's numeric addressing limit. + const info = yield* Effect.promise(() => file.handle.stat({ bigint: true })); + info.size = size; + const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info); + yield* Effect.addFinalizer(() => Effect.sync(() => statSpy.mockRestore())); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: canonicalPath, file, mimeType: "video/mp4" }, range), + ); + expect(response.status).toBe(status); + if (status === 416) { + expect(response.headers.get("content-range")).toBe(`bytes */${size}`); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (status === 206) { + expect(response.headers.get("content-range")).toBe(`bytes 0-1/${size}`); + expect(yield* Effect.promise(() => response.text())).toBe("01"); + } else { + expect(yield* Effect.promise(() => response.text())).toBe( + "File is too large to preview.", + ); + } + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("streams guarded file ranges, including suffixes and conditional requests", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-range-" }); + const filePath = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + for (const [range, ifRange, expected, status, contentRange] of [ + [undefined, undefined, "0123456789", 200, null], + ["bytes=0-1", undefined, "01", 206, "bytes 0-1/10"], + ["bytes=4-", undefined, "456789", 206, "bytes 4-9/10"], + ["bytes=-3", undefined, "789", 206, "bytes 7-9/10"], + ["bytes=-999999999999999999999999", undefined, "0123456789", 206, "bytes 0-9/10"], + ["bytes=10-", undefined, "", 416, "bytes */10"], + ["bytes=0-1", '"old-etag"', "0123456789", 200, null], + ["bytes=0-1", "", "0123456789", 200, null], + ] as const) { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + range, + ifRange, + ), + ); + expect(response.status).toBe(status); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(response.headers.get("content-range")).toBe(contentRange); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("etag")).toBeNull(); + expect(response.headers.get("last-modified")).toBeNull(); + if (status !== 416) + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-cleanup-" }); + const filePath = path.join(directory, "clip.mp4"); + const bytes = new Uint8Array(1024 * 1024).fill(42); + yield* fs.writeFile(filePath, bytes); + const canonicalPath = yield* fs.realPath(filePath); + for (const mode of ["full", "HEAD", "rejected", "cancelled"] as const) { + const file = yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + mode === "rejected" ? `bytes=${bytes.length}-` : "bytes=0-", + undefined, + mode === "HEAD" ? "HEAD" : "GET", + ), + ); + if (mode === "HEAD") { + expect(response.status).toBe(200); + expect(response.headers.get("content-length")).toBe(String(bytes.length)); + expect(response.headers.get("content-range")).toBeNull(); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (mode === "rejected") { + expect(response.status).toBe(416); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (mode === "cancelled") { + const reader = response.body!.getReader(); + const first = yield* Effect.promise(() => reader.read()); + expect(first.done).toBe(false); + expect(first.value!.byteLength).toBeLessThan(bytes.length); + yield* Effect.promise(() => reader.cancel()); + } else { + expect(yield* Effect.promise(() => response.arrayBuffer())).toEqual(bytes.buffer); + } + return file; + }), + ); + expect(file.handle.fd).toBe(-1); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + it.effect("streams exactly the requested bytes and leaves full downloads intact", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b83461775e91c..8b7c2ad3a61b6 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -29,6 +29,7 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, storeAttachmentUpload, @@ -124,6 +125,9 @@ function assetByteRange(header: string, size: bigint) { } const start = first ?? (last! >= size ? 0n : size - last!); const end = first === null || last === null || last >= size ? size - 1n : last; + if (!Number.isSafeInteger(Number(start)) || !Number.isSafeInteger(Number(end))) { + return { _tag: "Unsatisfiable" as const }; + } return { _tag: "Range" as const, offset: start, @@ -138,17 +142,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( readonly download?: boolean; readonly fileName?: string; readonly mimeType?: string; + readonly file?: OpenMediaFile; }, rangeHeader?: string, ifRangeHeader?: string, + method: "GET" | "HEAD" = "GET", ) { const headers = assetResponseHeaders(asset.path, asset); - if (headers["Content-Type"]?.toLowerCase().startsWith("video/")) { + const mediaFile = asset.file; + const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; + const isVideo = headers["Content-Type"]?.toLowerCase().startsWith("video/") === true; + if (mediaFile && isVideo) { + // Host videos can change in place. Do not invite conditional range requests + // with validators that cannot establish byte-for-byte identity. + headers["Cache-Control"] = "private, no-store"; + } + let status = 200; + let offset = 0n; + let bytesToRead: bigint | undefined; + if (isVideo) { headers["Accept-Ranges"] = "bytes"; // If-Range requires a matching validator. A full response is safe when we cannot validate it. - if (rangeHeader && !ifRangeHeader) { + if (method === "GET" && rangeHeader && ifRangeHeader === undefined) { const fs = yield* FileSystem.FileSystem; - const info = yield* fs.stat(asset.path); + const info = mediaInfo ?? (yield* fs.stat(asset.path)); const range = assetByteRange(rangeHeader, info.size); if (range?._tag === "Unsatisfiable") { return HttpServerResponse.empty({ @@ -157,16 +174,34 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( }); } if (range?._tag === "Range") { - return yield* HttpServerResponse.file(asset.path, { - status: 206, - offset: range.offset, - bytesToRead: range.bytesToRead, - headers: { ...headers, "Content-Range": range.contentRange }, - }); + status = 206; + offset = range.offset; + bytesToRead = range.bytesToRead; + headers["Content-Range"] = range.contentRange; } } } - return yield* HttpServerResponse.file(asset.path, { status: 200, headers }); + if (mediaFile && mediaInfo) { + const size = bytesToRead ?? mediaInfo.size; + headers["Content-Type"] ??= Mime.getType(asset.path) ?? "application/octet-stream"; + headers["Content-Length"] = String(size); + if (!isVideo) { + headers["Last-Modified"] = mediaInfo.mtime.toUTCString(); + headers.ETag = `W/"${mediaInfo.size.toString(16)}-${mediaInfo.mtimeMs.toString(16)}"`; + } + if (method === "HEAD" || size === 0n) { + return HttpServerResponse.empty({ status, headers }); + } + const body = streamMediaFile(mediaFile, offset, size); + if (!body) { + return HttpServerResponse.text("File is too large to preview.", { status: 413 }); + } + return HttpServerResponse.stream(body, { + status, + headers, + }); + } + return yield* HttpServerResponse.file(asset.path, { status, offset, bytesToRead, headers }); }); export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { @@ -338,6 +373,7 @@ export const assetRouteLayer = HttpRouter.add( asset, request.method === "GET" ? request.headers.range : undefined, request.headers["if-range"], + request.method === "HEAD" ? "HEAD" : "GET", ).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index f8c0b5ae75f70..5c642471404a6 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,11 +1,13 @@ import { useAtomValue } from "@effect/atom-react"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { assetEnvironment } from "~/state/assets"; import { usePreparedConnection } from "~/state/session"; +import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; @@ -49,6 +51,21 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc return result.url; } +/** Re-mints an exact-file capability after a file change or an explicit retry. */ +export function useAssetUrlRefresh( + environmentId: EnvironmentId, + resource: AssetResource, +): () => Promise { + const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + refresh: true, + }); + return useCallback(async () => { + const result = await refresh({ environmentId, input: { resource } }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + }, [environmentId, resource, refresh]); +} + export function useAssetUrls( environmentId: EnvironmentId, resources: ReadonlyArray, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 470f0efcc26c8..6f7327b3ab3e4 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -42,6 +42,9 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; +import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; +import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { @@ -76,7 +79,14 @@ import { renderCodexFileCitationsAsMarkdown, } from "@t3tools/client-runtime/codex-markdown-directives"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; -import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { + resolveMarkdownMediaPreview, + type ExpandedImagePreview, +} from "./chat/ExpandedImagePreview"; +import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; +import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; +import { MediaActions, type MediaActionSource } from "./media/MediaActions"; +import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { @@ -126,7 +136,7 @@ import { type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; -import { useAssetUrlState } from "../assets/assetUrls"; +import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; @@ -195,8 +205,14 @@ export function hasMarkdownFilePrimaryAction(input: { canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; + canOpenMedia?: boolean; }): boolean { - return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; + return ( + input.canOpenInEditor || + input.canOpenInBrowser || + input.canOpenInPanel || + input.canOpenMedia === true + ); } export function shouldUseMarkdownFileBrowserPrimaryAction(input: { @@ -1011,6 +1027,7 @@ interface MarkdownFileLinkProps { onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; + onOpenMedia?: (() => void) | undefined; onReveal?: (() => Promise>) | undefined; /** Platform-specific menu label ("Reveal in Finder", ...); required for the reveal item to show. */ @@ -1133,10 +1150,16 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: ); }); -const CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME = "max-h-[30rem] max-w-[min(100%,30rem)]"; +const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; +const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( + "max-h-[30rem]", + CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, +); +const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; +const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( "h-auto w-auto object-contain", - CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, ); function markdownImageCopy(alt: string, src: string, title: string | undefined): string { @@ -1167,11 +1190,10 @@ function authoredImageSizeStyle( return undefined; } -const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!"; const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, - CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME, - "rounded-lg border border-border/40", + CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, ); const MarkdownLinkContext = React.createContext(false); @@ -1179,6 +1201,8 @@ function expandableMarkdownImageProps( onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, src: string, alt: string, + originalUrl?: string, + actionsSource?: MediaActionSource, ) { if (!onImageExpand) return {}; const previewName = alt.trim() || "image"; @@ -1186,7 +1210,17 @@ function expandableMarkdownImageProps( if (event.currentTarget.closest("a")) return; event.preventDefault(); event.stopPropagation(); - onImageExpand({ images: [{ src, name: previewName }], index: 0 }); + onImageExpand({ + images: [ + { + src, + name: previewName, + ...(originalUrl ? { originalUrl } : {}), + ...(actionsSource ? { actionsSource } : {}), + }, + ], + index: 0, + }); }; return { role: "button" as const, @@ -1202,70 +1236,201 @@ function expandableMarkdownImageProps( function ChatMarkdownImageFallback(props: { readonly alt: string; readonly copyMarkdown?: string | undefined; + readonly kind?: "image" | "video"; + readonly actionsSource?: MediaActionSource; }) { - return ( + const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; + const content = ( - {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} + {props.alt.length > 0 ? `${label} · ${props.alt}` : label} ); + return props.actionsSource ? ( + {content} + ) : ( + content + ); +} + +function ChatMarkdownVideo(props: { + readonly src: string | null; + readonly alt: string; + readonly copyMarkdown: string | undefined; + readonly originalUrl?: string | undefined; + readonly sourceFailed?: boolean | undefined; + readonly style?: CSSProperties | undefined; + readonly mediaIdentity?: string | undefined; + readonly actionsSource?: MediaActionSource | undefined; + readonly onRetry?: (() => Promise) | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; +}) { + return ( + { + props.onImageExpand?.({ + images: [ + { + src, + name: props.alt || "video", + type: "video", + autoPlay: false, + ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), + ...(props.actionsSource + ? { actionsSource: { ...props.actionsSource, src } } + : {}), + }, + ], + index: 0, + }); + } + : undefined + } + /> + ); } -/** Environment-hosted images load through a signed asset URL. */ +/** Environment-hosted media loads through an exact-file signed asset URL. */ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract< + AssetResource, + { readonly _tag: "attachment" | "workspace-file" | "media-file" } + >; + readonly kind?: "image" | "video"; readonly alt: string; readonly copyMarkdown?: string; readonly srcFragment?: string; readonly style?: CSSProperties | undefined; + readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); + const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); const [failedUrl, setFailedUrl] = useState(null); + const resource = props.resource; + const path = + resource._tag === "media-file" + ? resource.path + : resource._tag === "workspace-file" && props.workspaceRoot + ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` + : undefined; + const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; + const relativePath = reference?.relativePath; + const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; + const actionsSource: MediaActionSource = { + kind: props.kind ?? "image", + name: props.alt || (props.kind ?? "image"), + src, + asset: { environmentId: props.environmentId, resource }, + ...(reference ? { reference } : {}), + ...(relativePath && resource._tag !== "attachment" + ? { + onOpenFile: () => + useRightPanelStore + .getState() + .openFile( + { environmentId: props.environmentId, threadId: resource.threadId }, + relativePath, + ), + } + : {}), + }; + + if (props.kind === "video") { + return ( + + ); + } if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { - return ; + return ( + + ); } if (assetUrl._tag !== "Success") { return ( - + + + ); + } + return ( + + {props.alt} setFailedUrl(assetUrl.url)} /> - ); - } - const src = assetUrl.url + (props.srcFragment ?? ""); - return ( - {props.alt} setFailedUrl(assetUrl.url)} - /> + ); }); @@ -1448,6 +1613,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onOpenInPanel, openInEditorMenuLabel, onOpenInBrowser, + onOpenMedia, onReveal, revealLabel, className, @@ -1491,12 +1657,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [onOpen, targetPath]); const handleOpenInFilePreview = useCallback(() => { - if (!threadRef || !workspaceRelativePath) { - handleOpenInEditor(); + if (threadRef && workspaceRelativePath) { + onOpenInPanel(workspaceRelativePath, line); return; } - onOpenInPanel(workspaceRelativePath, line); - }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); + if (onOpenMedia) { + onOpenMedia(); + return; + } + handleOpenInEditor(); + }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1621,6 +1791,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ try { const clicked = await api.contextMenu.show( [ + ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) @@ -1632,6 +1803,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ position, ); + if (clicked === "preview-media") { + onOpenMedia?.(); + return; + } if (clicked === "open") { handleOpenInEditor(); return; @@ -1665,6 +1840,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInEditor, handleRevealInFileManager, onOpenInBrowser, + onOpenMedia, onOpen, onReveal, openInEditorMenuLabel, @@ -1696,6 +1872,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ canOpenInEditor, canOpenInBrowser, canOpenInPanel, + canOpenMedia: onOpenMedia !== undefined, }); const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ iconPath, @@ -1787,6 +1964,7 @@ function areMarkdownFileLinkPropsEqual( previous.onOpenInPanel === next.onOpenInPanel && previous.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && + previous.onOpenMedia === next.onOpenMedia && previous.onReveal === next.onReveal && previous.revealLabel === next.revealLabel && previous.className === next.className @@ -1810,8 +1988,18 @@ function ChatMarkdown({ extraRemarkPlugins = EMPTY_REMARK_PLUGINS, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + const [localMediaPreview, setLocalMediaPreview] = useState(null); + const expandMedia = onImageExpand ?? setLocalMediaPreview; + const mediaRequestId = useRef(0); + useEffect(() => { + setLocalMediaPreview(null); + return () => { + mediaRequestId.current += 1; + }; + }, [threadRef?.environmentId, threadRef?.threadId, explicitEnvironmentId, cwd, imageBaseDir]); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, + refresh: true, }); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, @@ -1830,6 +2018,41 @@ function ChatMarkdown({ remoteOpen.isResolved, ); const preparedConnection = usePreparedConnection(environmentId); + const openMarkdownMedia = useCallback( + (source: string, resolvedFilePath?: string) => { + const requestId = ++mediaRequestId.current; + void resolveMarkdownMediaPreview({ + source, + resolvedFilePath, + cwd, + threadRef, + httpBaseUrl: + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : undefined, + createAssetUrl, + onOpenFile: threadRef + ? (path) => useRightPanelStore.getState().openFile(threadRef, path) + : undefined, + }).then( + (preview) => { + if (preview && mediaRequestId.current === requestId) expandMedia(preview); + }, + (error: unknown) => { + if (mediaRequestId.current !== requestId) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Media unavailable", + description: + error instanceof Error + ? error.message + : "The file could not be loaded. It may have been moved or deleted.", + }), + ); + }, + ); + }, + [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef], + ); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const threadServerConfig = useAtomValue( serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), @@ -2068,6 +2291,7 @@ function ChatMarkdown({ fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, className?: string, + mediaSource?: string, ) => { const parentSuffix = fileLinkParentSuffixByPath.get( fileLinkMeta.filePath.replaceAll("\\", "/"), @@ -2081,6 +2305,11 @@ function ChatMarkdown({ `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, ); } + const mediaPath = mediaSource ?? fileLinkMeta.filePath; + const canPreviewMedia = + mediaMimeTypeFromExtension( + fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), + ) !== null; return ( openMarkdownMedia(mediaPath, fileLinkMeta.filePath) + : undefined + } openInEditorMenuLabel={preferredEditorMenuLabel} onReveal={ canUseShellActions && revealInFileManagerLabel !== undefined @@ -2232,6 +2466,21 @@ function ChatMarkdown({ handleMarkdownFragmentClick(event, href); return; } + if ( + href && + faviconHost !== null && + mediaKindFromPath(href) !== null && + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + event.stopPropagation(); + openMarkdownMedia(href); + return; + } // A link to a change request in a workspace project opens beside the // conversation instead of in a browser: it is the thing being talked about, and // the panel it opens offers the browser as one of its actions. Anything else is @@ -2323,6 +2572,7 @@ function ChatMarkdown({ fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, props.className, + normalizedHref, ); }, code({ node, children, className, ...props }) { @@ -2332,7 +2582,12 @@ function ChatMarkdown({ inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? resolveInlineCodeFileLinkMeta(codeText, cwd); if (fileLinkMeta) { - return fileLinkChip(fileLinkMeta, `\`${codeText}\``); + return fileLinkChip( + fileLinkMeta, + `\`${codeText}\``, + undefined, + inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), + ); } } return ( @@ -2342,7 +2597,7 @@ function ChatMarkdown({ ); }, img: function MarkdownImage({ node, title, src, alt, ...props }) { - const imageExpand = use(MarkdownLinkContext) ? undefined : onImageExpand; + const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const localSrc = node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; const authoredSrc = typeof localSrc === "string" ? localSrc : src; @@ -2355,21 +2610,53 @@ function ChatMarkdown({ const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); + const kind = mediaKindFromPath(classifiedSrc) ?? "image"; if (imageSource._tag === "Direct") { + const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); + const originalUrl = + resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined; + const reference = mediaUrlReference(imageSource.uri); + const actionsSource: MediaActionSource = { + kind, + name: altText || kind, + src: mediaSrc, + ...(reference ? { reference } : {}), + }; + if (kind === "video") { + return ( + + ); + } return ( - {altText} + + {altText} + ); } if (imageSource._tag === "WorkspaceFile" && threadRef) { @@ -2377,19 +2664,21 @@ function ChatMarkdown({ ); } - return ; + return ; }, table({ node: _node, ...props }) { return ; @@ -2438,6 +2727,8 @@ function ChatMarkdown({ onTaskListChange, onUseArtifactTemplate, onImageExpand, + expandMedia, + openMarkdownMedia, openFileInPanel, openInPreferredEditor, openChangeRequestLink, @@ -2483,6 +2774,12 @@ function ChatMarkdown({ > {text} + {localMediaPreview ? ( + setLocalMediaPreview(null)} + /> + ) : null}
); } diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 39ea214d43c38..172793bacb0cc 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -9,6 +9,7 @@ const testState = vi.hoisted(() => ({ vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("../assets/assetUrls", () => ({ + useAssetUrlRefresh: () => vi.fn(), useAssetUrlState: (_environmentId: unknown, resource: unknown) => { testState.resources.push(resource); if (testState.assetState === "loading") return { _tag: "Loading" }; @@ -106,7 +107,7 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: expectedPath, }, @@ -126,14 +127,14 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", }, - { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, - { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "\\\\server\\share\\workspace-image.svg", }, @@ -149,7 +150,7 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "D:/screens/workspace-image.svg", }, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 6e391ab79e954..64ad1ce7d7827 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -22,7 +22,6 @@ import { dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, - loadVideoPreviewUrl, isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, @@ -44,23 +43,6 @@ import { shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; -describe("loadVideoPreviewUrl", () => { - it("loads video bytes into an object URL", async () => { - const objectUrl = await loadVideoPreviewUrl("data:video/mp4;base64,AA=="); - expect(objectUrl).toMatch(/^blob:/); - URL.revokeObjectURL(objectUrl); - }); - - it("stops loading when the preview request is cancelled", async () => { - const controller = new AbortController(); - controller.abort(); - - await expect( - loadVideoPreviewUrl("data:video/mp4;base64,AA==", controller.signal), - ).rejects.toMatchObject({ name: "AbortError" }); - }); -}); - describe("isVideoPreviewRequestCurrent", () => { it("rejects changed threads and replaced previews", () => { expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a12bacad50dd3..de349e3dbee72 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,7 @@ import { + type AssetCreateUrlInput, + type AssetCreateUrlResult, + type ChatFileAttachment, type EnvironmentId, isProviderDriverKind, ProjectId, @@ -12,6 +15,12 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { videoMimeType } from "@t3tools/shared/video"; import { appendCodexArtifactTemplateUsePrompt, codexArtifactTemplateUsePrompt, @@ -299,10 +308,32 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { URL.revokeObjectURL(previewUrl); } -export async function loadVideoPreviewUrl(url: string, signal?: AbortSignal): Promise { - const response = await fetch(url, signal ? { signal } : {}); - if (!response.ok) throw new Error(`Could not load video (${response.status}).`); - return URL.createObjectURL(await response.blob()); +/** Signs an attachment URL without reading its bytes, so video playback can request byte ranges. */ +export async function resolveFileAttachmentUrl(input: { + attachment: ChatFileAttachment; + environmentId: EnvironmentId; + httpBaseUrl: string; + createAssetUrl: (input: { + environmentId: EnvironmentId; + input: AssetCreateUrlInput; + }) => Promise>; +}): Promise { + const { attachment } = input; + const result = await input.createAssetUrl({ + environmentId: input.environmentId, + input: { + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + }, + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); + if (url === null) throw new Error("The environment returned an invalid attachment URL."); + return url; } export function isVideoPreviewRequestCurrent( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a475230fe8299..dc0d2ed122f5b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -365,7 +365,7 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, - loadVideoPreviewUrl, + resolveFileAttachmentUrl, isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, @@ -424,7 +424,7 @@ import { resolveServerSelfUpdateCapability, serverUpdateGuidance, } from "../versionSkew"; -import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls"; +import { useAssetUrls } from "../assets/assetUrls"; const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; @@ -1331,6 +1331,7 @@ function ChatViewContent(props: ChatViewProps) { const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, + refresh: true, }); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, @@ -1459,11 +1460,8 @@ function ChatViewContent(props: ChatViewProps) { const routeThreadKeyRef = useRef(routeThreadKey); routeThreadKeyRef.current = routeThreadKey; const videoPreviewRequestIdRef = useRef(0); - const videoPreviewAbortControllerRef = useRef(null); const cancelVideoPreviewRequest = useCallback(() => { videoPreviewRequestIdRef.current += 1; - videoPreviewAbortControllerRef.current?.abort(); - videoPreviewAbortControllerRef.current = null; }, []); const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -2542,14 +2540,8 @@ function ChatViewContent(props: ChatViewProps) { toastManager.add({ type: "error", title: "The environment is not connected." }); return; } - const videoMime = videoMimeType(attachment); - const isVideo = videoMime !== null; + const isVideo = videoMimeType(attachment) !== null; const action = isVideo ? "play" : "download"; - const videoPreviewAbortController = isVideo ? new AbortController() : null; - if (isVideo) { - videoPreviewAbortControllerRef.current?.abort(); - videoPreviewAbortControllerRef.current = videoPreviewAbortController; - } const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; const isCurrentRequest = () => !isVideo || @@ -2559,75 +2551,37 @@ function ChatViewContent(props: ChatViewProps) { videoPreviewRequestId, videoPreviewRequestIdRef.current, ); - const finishVideoPreviewRequest = () => { - if (videoPreviewRequestIdRef.current === videoPreviewRequestId) { - setOpeningVideoAttachmentId(null); - videoPreviewAbortControllerRef.current = null; - } - }; if (isVideo) setOpeningVideoAttachmentId(attachment.id); - // fileName and mimeType ride in the signed claims so videos render - // inline while other files keep their real download name and type. - const result = await createAttachmentAssetUrl({ - environmentId, - input: { - resource: { - _tag: "attachment", - attachmentId: attachment.id, - fileName: attachment.name, - mimeType: videoMime ?? attachment.mimeType, - }, - }, - }); - if (!isCurrentRequest()) { - finishVideoPreviewRequest(); - return; - } - if (result._tag === "Failure") { - finishVideoPreviewRequest(); - const error = squashAtomCommandFailure(result); - toastManager.add({ - type: "error", - title: "Could not " + action + " " + attachment.name, - description: error instanceof Error ? error.message : "The attachment is unavailable.", + try { + const url = await resolveFileAttachmentUrl({ + attachment, + environmentId, + httpBaseUrl: connection.httpBaseUrl, + createAssetUrl: createAttachmentAssetUrl, }); - return; - } - - const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); - if (!url) { - finishVideoPreviewRequest(); - toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name }); - return; - } - if (isVideo) { - try { - const previewUrl = await loadVideoPreviewUrl(url, videoPreviewAbortController?.signal); - if (!isCurrentRequest()) { - revokeBlobPreviewUrl(previewUrl); - return; - } + if (!isCurrentRequest()) return; + if (isVideo) { setExpandedImage({ - images: [{ src: previewUrl, name: attachment.name, type: "video" }], + images: [{ src: url, name: attachment.name, type: "video" }], index: 0, }); - } catch (error) { - if (!isCurrentRequest()) return; - toastManager.add({ - type: "error", - title: "Could not play " + attachment.name, - description: error instanceof Error ? error.message : "The attachment is unavailable.", - }); - } finally { - finishVideoPreviewRequest(); + return; } - return; + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = attachment.name; + anchor.click(); + } catch (error) { + if (!isCurrentRequest()) return; + toastManager.add({ + type: "error", + title: "Could not " + action + " " + attachment.name, + description: error instanceof Error ? error.message : "The attachment is unavailable.", + }); + } finally { + if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null); } - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = attachment.name; - anchor.click(); }, [createAttachmentAssetUrl, environmentId, routeThreadKey], ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cd69eceb7efdc..d74f1f0d8e7f8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -172,6 +172,7 @@ import { submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; +import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( @@ -189,9 +190,7 @@ function ComposerVideoThumbnail({ file }: { file: File }) { playsInline preload="metadata" aria-hidden="true" - onLoadedMetadata={(event) => { - event.currentTarget.currentTime = Math.min(0.1, event.currentTarget.duration || 0); - }} + onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} className="pointer-events-none absolute inset-0 size-full object-cover" /> ); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.test.tsx deleted file mode 100644 index c2a63f34ecde4..0000000000000 --- a/apps/web/src/components/chat/ExpandedImageDialog.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { ExpandedImageDialog } from "./ExpandedImageDialog"; -import type { ExpandedImagePreview } from "./ExpandedImagePreview"; - -describe("ExpandedImageDialog", () => { - it("renders video previews with native controls", () => { - const preview: ExpandedImagePreview = { - images: [ - { - src: "https://environment.test/api/assets/demo.mp4", - name: "demo.mp4", - type: "video", - }, - ], - index: 0, - }; - - const markup = renderToStaticMarkup( - {}} />, - ); - - expect(markup).toContain(" void; } +function ExpandedMediaFailure({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + export const ExpandedImageDialog = memo(function ExpandedImageDialog({ preview, onClose, }: ExpandedImageDialogProps) { const [imageOffset, setImageOffset] = useState(0); const [failedVideoSrc, setFailedVideoSrc] = useState(null); - const [downloadingVideoSrc, setDownloadingVideoSrc] = useState(null); - const [downloadFailedVideoSrc, setDownloadFailedVideoSrc] = useState(null); + const [failedImageSrc, setFailedImageSrc] = useState(null); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; + const item = preview.images[index]; + const source: MediaActionSource = item?.actionsSource ?? { + kind: item?.type === "video" ? "video" : "image", + name: item?.name ?? "Media", + src: item?.src ?? null, + }; + const openFile = source.onOpenFile; + const actionsSource: MediaActionSource = openFile + ? { + ...source, + onOpenFile: () => { + openFile(); + onClose(); + }, + } + : source; const navigateImage = useCallback((direction: -1 | 1) => { setImageOffset((current) => current + direction); }, []); - const downloadVideo = async (src: string, name: string) => { - setDownloadFailedVideoSrc(null); - setDownloadingVideoSrc(src); - try { - await downloadVideoPreview(src, name); - } catch { - setDownloadFailedVideoSrc(src); - } finally { - setDownloadingVideoSrc((current) => (current === src ? null : current)); - } - }; - useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented || isContextMenuOpen()) { + return; + } if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); @@ -58,13 +81,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ return () => window.removeEventListener("keydown", onKeyDown); }, [navigateImage, onClose, preview.images.length]); - const item = preview.images[index]; if (!item) return null; const mediaLabel = item.type === "video" ? "video" : "image"; + const openOriginalLink = + item.originalUrl && resolveExternalWebLinkHost(item.originalUrl) !== null ? ( + + ) : null; - const isDownloadingVideo = downloadingVideoSrc === item.src; - const videoDownloadFailed = downloadFailedVideoSrc === item.src; - return ( + return createPortal(
)} -
- - {item.type === "video" && failedVideoSrc === item.src ? ( -
-

- {videoDownloadFailed - ? "Could not download this video." - : "This video format cannot be played here."} -

- -
- ) : item.type === "video" ? ( -
+ +
+ + {item.type === "video" && failedVideoSrc === item.src ? ( + +

This video could not be loaded or played.

+ +
+ ) : item.type === "video" ? ( +
+
{preview.images.length > 1 && ( )} -
+
, + document.body, ); }); diff --git a/apps/web/src/components/chat/ExpandedImagePreview.test.ts b/apps/web/src/components/chat/ExpandedImagePreview.test.ts index 71979a3cb0134..f75ba86e4ec67 100644 --- a/apps/web/src/components/chat/ExpandedImagePreview.test.ts +++ b/apps/web/src/components/chat/ExpandedImagePreview.test.ts @@ -1,12 +1,46 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { ComposerFileAttachment } from "../../composerDraftStore"; import { attachVideoThumbnail, buildExpandedImagePreview, - downloadVideoPreview, + resolveMarkdownMediaPreview, } from "./ExpandedImagePreview"; +describe("resolveMarkdownMediaPreview", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + ["t3code:", "https:"], + ["t3code-dev:", "https:"], + ["http:", "http:"], + ["https:", "https:"], + ])( + "resolves protocol-relative media on %s without changing its source", + async (pageProtocol, mediaProtocol) => { + vi.stubGlobal("window", { location: { protocol: pageProtocol } }); + const source = "//cdn.example.com/recording.mp4?token=a%2FB&v=1#t=2"; + const preview = await resolveMarkdownMediaPreview({ + source, + createAssetUrl: async () => { + throw new Error("Remote media must not request a local asset URL."); + }, + }); + + expect(preview?.images[0]).toMatchObject({ + src: `${mediaProtocol}${source}`, + originalUrl: source, + actionsSource: { + src: `${mediaProtocol}${source}`, + reference: { kind: "url", url: source }, + }, + }); + }, + ); +}); + describe("buildExpandedImagePreview", () => { it("builds a video preview for a local video attachment", () => { const file = new File([new Uint8Array([1, 2, 3])], "demo.mp4", { type: "video/mp4" }); @@ -40,29 +74,4 @@ describe("buildExpandedImagePreview", () => { detach(); await expect(fetch(url)).rejects.toThrow(); }); - - it("downloads a video through a local blob URL", async () => { - vi.useFakeTimers(); - const source = URL.createObjectURL(new Blob([new Uint8Array([1, 2, 3])])); - const click = vi.fn(); - const anchor = { href: "", download: "", click }; - vi.stubGlobal("document", { createElement: () => anchor }); - - try { - await downloadVideoPreview(source, "demo.mp4"); - - expect(anchor.download).toBe("demo.mp4"); - expect(anchor.href).toMatch(/^blob:/); - expect(anchor.href).not.toBe(source); - expect(click).toHaveBeenCalledOnce(); - expect((await fetch(anchor.href)).ok).toBe(true); - - await vi.runAllTimersAsync(); - await expect(fetch(anchor.href)).rejects.toThrow(); - } finally { - URL.revokeObjectURL(source); - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }); }); diff --git a/apps/web/src/components/chat/ExpandedImagePreview.tsx b/apps/web/src/components/chat/ExpandedImagePreview.tsx index b57a292e0bf56..cddfb95052b90 100644 --- a/apps/web/src/components/chat/ExpandedImagePreview.tsx +++ b/apps/web/src/components/chat/ExpandedImagePreview.tsx @@ -1,10 +1,34 @@ import type { ComposerFileAttachment } from "../../composerDraftStore"; import { type ChatImageAttachment, isVideoAttachment } from "../../types"; +import type { + AssetCreateUrlResult, + AssetResource, + EnvironmentId, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; +import type { MediaActionSource } from "../media/MediaActions"; +import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent"; export interface ExpandedImageItem { src: string; name: string; type?: "video"; + autoPlay?: boolean; + /** Authored remote destination to open when embedding fails, never a generated asset URL. */ + originalUrl?: string; + actionsSource?: MediaActionSource; } export interface ExpandedImagePreview { @@ -12,23 +36,98 @@ export interface ExpandedImagePreview { index: number; } +/** Resolves a chat media reference on its owning environment, without downloading its bytes. */ +export async function resolveMarkdownMediaPreview(input: { + source: string; + resolvedFilePath?: string | undefined; + cwd?: string | undefined; + threadRef?: ScopedThreadRef | undefined; + httpBaseUrl?: string | undefined; + onOpenFile?: ((relativePath: string) => void) | undefined; + createAssetUrl: (input: { + environmentId: EnvironmentId; + input: { resource: AssetResource }; + }) => Promise>; +}): Promise { + const source = + input.resolvedFilePath === undefined + ? classifyMarkdownImageSource(input.source, input.cwd) + : { _tag: "WorkspaceFile" as const, path: input.resolvedFilePath }; + if (source._tag === "Blocked") return null; + + const path = + source._tag === "Direct" + ? source.uri.split(/[?#]/, 1)[0]! + : source.path.replace(/:\d+(?::\d+)?$/, ""); + const name = path.split(/[\\/]/).at(-1) ?? ""; + const extensionIndex = name.lastIndexOf("."); + const fileMimeType = + extensionIndex < 0 ? null : mediaMimeTypeFromExtension(name.slice(extensionIndex)); + const kind = + source._tag === "Direct" + ? mediaKindFromPath(source.uri) + : fileMimeType === null + ? null + : fileMimeType.startsWith("video/") + ? "video" + : "image"; + if (kind === null) return null; + + const reference = + source._tag === "Direct" ? mediaUrlReference(source.uri) : mediaFileReference(path, input.cwd); + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + let src: string; + let asset: MediaActionSource["asset"]; + if (source._tag === "Direct") { + src = resolveProtocolRelativeMediaUrl(source.uri); + } else { + if (!input.threadRef || !input.httpBaseUrl) { + throw new Error("Reconnect to this environment and open the media again."); + } + asset = { + environmentId: input.threadRef.environmentId, + resource: { _tag: "media-file", threadId: input.threadRef.threadId, path }, + }; + const result = await input.createAssetUrl({ + environmentId: asset.environmentId, + input: { resource: asset.resource }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); + if (assetUrl === null) throw new Error("The environment returned an invalid media URL."); + src = assetUrl + markdownImageSourceFragment(input.source); + } + return { + images: [ + { + src, + name: name || kind, + ...(kind === "video" ? { type: "video", autoPlay: false } : {}), + ...(source._tag === "Direct" && resolveExternalWebLinkHost(source.uri) !== null + ? { originalUrl: source.uri } + : {}), + actionsSource: { + kind, + name: name || kind, + src, + ...(reference ? { reference } : {}), + ...(asset ? { asset } : {}), + ...(relativePath && input.onOpenFile + ? { onOpenFile: () => input.onOpenFile?.(relativePath) } + : {}), + }, + }, + ], + index: 0, + }; +} + export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void { const url = URL.createObjectURL(file); video.src = url; return () => URL.revokeObjectURL(url); } -export async function downloadVideoPreview(src: string, name: string): Promise { - const response = await fetch(src); - if (!response.ok) throw new Error(`Could not download video (${response.status}).`); - const url = URL.createObjectURL(await response.blob()); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = name; - anchor.click(); - setTimeout(() => URL.revokeObjectURL(url), 30_000); -} - export function buildExpandedImagePreview( images: ReadonlyArray, selectedImageId: string, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index df944a98485e4..2f9cd56c893c2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2556,6 +2556,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { resource={viewedImage.resource} alt={viewedImage.alt} srcFragment={viewedImage.srcFragment} + workspaceRoot={workspaceRoot} style={{ maxHeight: "16rem" }} onImageExpand={onImageExpand} /> diff --git a/apps/web/src/components/chat/externalLinkContextMenu.test.ts b/apps/web/src/components/chat/externalLinkContextMenu.test.ts index 9eb924280f8f7..5761d4f951466 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.test.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.test.ts @@ -181,6 +181,8 @@ describe("external chat link context menu", () => { it.each([ ["https://example.com", "example.com"], ["http://localhost:3000/path", "localhost"], + ["//cdn.example.com/clip.mp4?signature=abc#t=2", "cdn.example.com"], + ["//", null], ["#details", null], ["mailto:hello@example.com", null], ["file:///tmp/example.txt", null], diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts index b31f27668ade1..d0f37f97d8002 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -76,7 +76,7 @@ interface ShowExternalLinkContextMenuOptions { export function resolveExternalWebLinkHost(href: string | undefined): string | null { if (!href) return null; try { - const url = new URL(href); + const url = new URL(href.startsWith("//") ? `https:${href}` : href); if (url.protocol !== "http:" && url.protocol !== "https:") return null; return url.hostname || null; } catch { diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index f9ebb30762884..1272fa7d49746 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -4,7 +4,10 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; -import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { + isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, +} from "@t3tools/shared/filePreview"; import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; @@ -12,13 +15,16 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; -import { useAssetUrlState } from "~/assets/assetUrls"; +import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; +import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; +import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; @@ -135,37 +141,53 @@ function WorkspaceImagePreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly absolutePath: string; + readonly workspaceRoot: string; readonly alt: string; readonly workspaceMutationId: string | null; }) { - const assetUrl = useAssetUrlState(props.environmentId, { - _tag: "workspace-file", - threadId: props.threadRef.threadId, - path: props.absolutePath, - }); + const resource = useMemo( + () => ({ + _tag: "workspace-file" as const, + threadId: props.threadRef.threadId, + path: props.absolutePath, + }), + [props.threadRef.threadId, props.absolutePath], + ); + const assetUrl = useAssetUrlState(props.environmentId, resource); const [failedUrl, setFailedUrl] = useState(null); const revisionSuffix = props.workspaceMutationId === null ? "" : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; + const actionsSource: MediaActionSource = { + kind: "image", + name: props.alt, + src: imageUrl, + reference: mediaFileReference(props.absolutePath, props.workspaceRoot), + asset: { environmentId: props.environmentId, resource }, + }; if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { return ( -
- Unable to load workspace image. -
+ +
+ Unable to load workspace image. +
+
); } return assetUrl._tag === "Success" && imageUrl !== null ? (
- {props.alt} setFailedUrl(imageUrl)} - /> + + {props.alt} setFailedUrl(imageUrl)} + /> +
) : (
@@ -174,6 +196,60 @@ function WorkspaceImagePreview(props: { ); } +function WorkspaceVideoPreview(props: { + readonly environmentId: EnvironmentId; + readonly threadRef: ScopedThreadRef; + readonly absolutePath: string; + readonly workspaceRoot: string; + readonly name: string; + readonly workspaceMutationId: string | null; +}) { + const resource = useMemo( + () => ({ + _tag: "media-file" as const, + threadId: props.threadRef.threadId, + path: props.absolutePath, + }), + [props.threadRef.threadId, props.absolutePath], + ); + const assetUrl = useAssetUrlState(props.environmentId, resource); + const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); + useWorkspaceMutationRefresh({ + mutationId: props.workspaceMutationId, + resourceKey: JSON.stringify([props.environmentId, resource]), + refresh: () => { + // Failed refreshes flow through assetUrl and can be retried from the player. + void refreshAssetUrl().catch(() => undefined); + }, + }); + const revisionSuffix = + props.workspaceMutationId === null + ? "" + : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; + const latestUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; + + return ( +
+ +
+ ); +} + function clampFileLine(contents: string, requestedLine: number): number { let lineCount = 1; for (let index = 0; index < contents.length; index += 1) { @@ -791,8 +867,10 @@ export default function FilePreviewPanel({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath); - const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage); + const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); + const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); + const isMedia = isImage || isVideo; + const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); // Reading markdown rendered is a preference, not a property of one file. Keeping // it on the panel meant a thread switch dropped it and forced source back. @@ -816,7 +894,10 @@ export default function FilePreviewPanel({ (revealLine === null || (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId)); const canOpenInBrowser = - relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); + relativePath !== null && + !isVideo && + isPreviewSupportedInRuntime() && + isBrowserPreviewFile(relativePath); const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; const breadcrumbs = useMemo( () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), @@ -824,7 +905,7 @@ export default function FilePreviewPanel({ ); const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); useWorkspaceMutationRefresh({ - enabled: relativePath !== null && !isImage && !selectedFilePending, + enabled: relativePath !== null && !isMedia && !selectedFilePending, mutationId: workspaceMutationId, refresh: file.refresh, resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, @@ -999,7 +1080,7 @@ export default function FilePreviewPanel({
) : null} - {relativePath && file.data?.truncated ? ( + {relativePath && !isMedia && file.data?.truncated ? (
Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
@@ -1011,12 +1092,23 @@ export default function FilePreviewPanel({ relativePath ? "flex" : "hidden", )} > - {relativePath && isImage && absolutePath ? ( + {relativePath && isVideo && absolutePath ? ( + + ) : relativePath && isImage && absolutePath ? ( @@ -1099,7 +1191,7 @@ export default function FilePreviewPanel({ selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} workspaceMutationId={workspaceMutationId} - {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})} + {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})} /> ) : null} diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 08203ff6b87a0..d02ec99605ba9 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -4,6 +4,10 @@ import type { ProjectListEntriesResult, ProjectReadFileResult, } from "@t3tools/contracts"; +import { + isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, +} from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -178,9 +182,13 @@ export function useProjectFileQuery( relativePath: string | null, enabled = true, ): ProjectQueryState { - const atom = enabled - ? getProjectFileQueryAtom(environmentId, cwd, relativePath) - : EMPTY_PROJECT_FILE_QUERY_ATOM; + const isMedia = + relativePath !== null && + (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); + const atom = + enabled && !isMedia + ? getProjectFileQueryAtom(environmentId, cwd, relativePath) + : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx new file mode 100644 index 0000000000000..a67cadbca5a12 --- /dev/null +++ b/apps/web/src/components/media/MediaActions.tsx @@ -0,0 +1,189 @@ +import { + mediaReferenceFileName, + type MediaReference, +} from "@t3tools/client-runtime/media-reference"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { AssetResource, ContextMenuItem, EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useRef, useState, type ReactElement } from "react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { readLocalApi } from "../../localApi"; +import { assetEnvironment } from "../../state/assets"; +import { readPreparedConnection } from "../../state/session"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { downloadMedia, readMediaPng } from "./mediaContent"; + +export interface MediaActionSource { + readonly kind: "image" | "video"; + readonly name: string; + readonly src: string | null; + readonly reference?: MediaReference; + readonly asset?: { readonly environmentId: EnvironmentId; readonly resource: AssetResource }; + readonly onOpenFile?: () => void; +} + +function mediaFileName(source: MediaActionSource): string { + return ( + (source.reference && mediaReferenceFileName(source.reference)) || source.name || source.kind + ); +} + +/** Explicit byte operations get fresh capabilities without replacing a player's active source. */ +export function useMediaActions(source: MediaActionSource) { + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + refresh: true, + }); + const actionUrl = useCallback(async () => { + if (!source.asset) { + if (!source.src) throw new Error("This media is unavailable. Try reopening the preview."); + return source.src; + } + const { environmentId, resource } = source.asset; + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error("Reconnect to this environment and try again."); + const result = await createAssetUrl({ environmentId, input: { resource } }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (!url) throw new Error("The environment returned an invalid media URL."); + return url; + }, [source, createAssetUrl]); + const save = useCallback(async () => { + await downloadMedia(await actionUrl(), mediaFileName(source)); + }, [actionUrl, source]); + const copyImage = useCallback(async () => { + if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { + throw new Error( + "Image copying is unavailable. Use a secure browser connection or save the image.", + ); + } + // Start the clipboard write in the user gesture; fetching/decoding may finish later. + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }), + ]); + }, [actionUrl]); + return { save, copyImage }; +} + +type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file"; + +/** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ +export function MediaActions({ + source, + children, +}: { + source: MediaActionSource; + children: ReactElement; +}) { + const { save, copyImage } = useMediaActions(source); + const [tooltipOpen, setTooltipOpen] = useState(false); + const menuOpen = useRef(false); + const reference = source.reference; + const hasActions = + source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined; + const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); + + const showMenu = async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api || menuOpen.current) return; + menuOpen.current = true; + setTooltipOpen(false); + let failureTitle = "Could not open media menu"; + let progressToast: ReturnType | undefined; + try { + const items: ContextMenuItem[] = []; + if (reference?.kind === "file") { + items.push({ id: "copy-full", label: "Copy full path" }); + if (reference.relativePath) + items.push({ id: "copy-relative", label: "Copy relative path" }); + } else if (reference?.kind === "url") { + items.push({ id: "copy-url", label: "Copy URL" }); + } + if (source.kind === "image") { + const unavailable = source.src === null && source.asset === undefined; + items.push({ id: "save", label: "Save image", disabled: unavailable }); + items.push({ id: "copy-image", label: "Copy image", disabled: unavailable }); + } + if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); + + const action = await api.contextMenu.show(items, position); + if (!action) return; + failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; + const text = + action === "copy-full" && reference?.kind === "file" + ? reference.path + : action === "copy-relative" && reference?.kind === "file" + ? reference.relativePath + : action === "copy-url" && reference?.kind === "url" + ? reference.url + : undefined; + if (text !== undefined) { + await writeTextToClipboard(text, reference?.kind === "file" ? "file path" : "URL"); + toastManager.add({ + type: "success", + title: action === "copy-url" ? "URL copied" : "Path copied", + }); + } else if (action === "open-file") { + source.onOpenFile?.(); + } else if (action === "save" || action === "copy-image") { + progressToast = toastManager.add({ + type: "loading", + title: action === "save" ? "Preparing image download…" : "Copying image…", + }); + await (action === "save" ? save() : copyImage()); + toastManager.update(progressToast, { + type: "success", + title: action === "save" ? "Download started" : "Image copied", + }); + } + } catch (error) { + const toast = stackedThreadToast({ + type: "error", + title: failureTitle, + description: error instanceof Error ? error.message : "The media action failed.", + }); + if (progressToast) toastManager.update(progressToast, toast); + else toastManager.add(toast); + } finally { + menuOpen.current = false; + } + }; + + return ( + + { + if (!hasActions || event.defaultPrevented) return; + event.preventDefault(); + event.stopPropagation(); + const bounds = event.currentTarget.getBoundingClientRect(); + void showMenu( + event.clientX === 0 && event.clientY === 0 + ? { x: bounds.left, y: bounds.bottom } + : { x: event.clientX, y: event.clientY }, + ); + }} + onKeyDown={(event) => { + if ( + !hasActions || + event.defaultPrevented || + !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) + ) + return; + event.preventDefault(); + event.stopPropagation(); + const bounds = event.currentTarget.getBoundingClientRect(); + void showMenu({ x: bounds.left, y: bounds.bottom }); + }} + /> + + {tooltip} + + + ); +} diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx new file mode 100644 index 0000000000000..8f2d75680c14a --- /dev/null +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -0,0 +1,197 @@ +import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; + +import { cn } from "../../lib/utils"; +import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; +import { Button } from "../ui/button"; +import { OpenMediaLink } from "./OpenMediaLink"; +import { MediaActions, type MediaActionSource } from "./MediaActions"; + +interface MediaVideoPlayerProps { + readonly src: string | null; + readonly label: string; + readonly sourceFailed?: boolean | undefined; + readonly originalUrl?: string | undefined; + readonly revision?: string | null | undefined; + readonly preload?: "visible" | "metadata" | undefined; + readonly className?: string | undefined; + readonly videoClassName?: string | undefined; + readonly style?: CSSProperties | undefined; + readonly copyMarkdown?: string | undefined; + readonly onExpand?: ((src: string) => void) | undefined; + readonly onRetry?: (() => Promise) | undefined; + readonly actionsSource?: MediaActionSource | undefined; +} + +/** Keeps native range streaming and playback state consistent across inline and file previews. */ +export function MediaVideoPlayer({ + src: latestSrc, + label, + sourceFailed = false, + originalUrl, + revision = null, + preload = "visible", + className, + videoClassName, + style, + copyMarkdown, + onExpand, + onRetry, + actionsSource, +}: MediaVideoPlayerProps) { + const videoRef = useRef(null); + const [playbackSource, setPlaybackSource] = useState<{ + src: string; + revision: string | null; + } | null>(null); + const [failedSrc, setFailedSrc] = useState(null); + const [retrying, setRetrying] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); + const [preloadedSrc, setPreloadedSrc] = useState(null); + const src = playbackSource?.src ?? latestSrc; + const sourceRevision = playbackSource === null ? revision : playbackSource.revision; + const failed = src !== null ? failedSrc === src : sourceFailed; + + // Re-signing must not reset the playhead. Changed files refresh once playback pauses. + const refreshPausedRevision = useCallback(() => { + const video = videoRef.current; + if (video === null || video.paused || video.ended) { + setPlaybackSource((current) => + current !== null && current.revision !== revision ? null : current, + ); + } + }, [revision]); + useEffect(refreshPausedRevision, [refreshPausedRevision]); + + useEffect(() => { + const video = videoRef.current; + if (!video || preload === "metadata" || preloadedSrc === src) return; + if (typeof IntersectionObserver === "undefined") { + setPreloadedSrc(src); + return; + } + let active = true; + const observer = new IntersectionObserver( + (entries) => { + if (!active || !entries.some((entry) => entry.isIntersecting)) return; + setPreloadedSrc(src); + observer.disconnect(); + }, + { rootMargin: "200px" }, + ); + observer.observe(video); + return () => { + active = false; + observer.disconnect(); + }; + }, [src, preload, preloadedSrc, failed, loadAttempt]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const pauseWhenHidden = () => { + if (document.hidden) video.pause(); + }; + document.addEventListener("visibilitychange", pauseWhenHidden); + return () => { + document.removeEventListener("visibilitychange", pauseWhenHidden); + video.pause(); + }; + }, [src, failed, loadAttempt]); + + const retry = async () => { + if (retrying) return; + setRetrying(true); + try { + await onRetry?.(); + setPlaybackSource(null); + setFailedSrc(null); + setLoadAttempt((current) => current + 1); + } catch { + setFailedSrc(src); + } finally { + setRetrying(false); + } + }; + + const expandButton = + onExpand && src !== null ? ( + + ) : null; + + const player = ( + + {failed ? ( + + + + Video unavailable{label ? ` · ${label}` : ""} + + + {latestSrc !== null || onRetry ? ( + + ) : null} + + {expandButton} + + + ) : src !== null ? ( + @@ -715,7 +715,7 @@ export function ProviderInstanceCard({ "size-5 rounded-sm p-0", versionAdvisory.emphasis === "strong" ? "text-warning hover:text-warning" - : "text-update-foreground hover:text-update-foreground", + : "text-muted-foreground hover:text-foreground", )} aria-label="Update available — view details" > @@ -748,7 +748,7 @@ export function ProviderInstanceCard({ + ); + return ( - - - - - } - /> - 0 - ? // pointer-events-auto overrides the positioner's pointer-events-none so the - // release notes stay open (and scrollable) when the cursor moves into them. - "pointer-events-auto max-w-none text-balance" - : undefined + { + if (open && !showReleaseNotesPopover) { + details.cancel(); + return; } - side="top" - variant={showUpdateDetails ? "glass" : "default"} - > - {showUpdateDetails && state ? ( - - ) : ( - tooltip - )} - - + handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details); + }} + > + + + } + /> + {!showReleaseNotesPopover ? ( + + {tooltip} + + ) : null} + + {showReleaseNotesPopover && state ? ( + { + if ( + event.key === "Escape" && + releaseNotesPopupRef.current?.contains(document.activeElement) + ) { + suppressReleaseNotesFocusOpen.current = true; + } + }} + ref={releaseNotesPopupRef} + side="top" + tooltipStyle + > + + + ) : null} + ); } diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx new file mode 100644 index 0000000000000..d229e2d3cabb6 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx @@ -0,0 +1,152 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; +import { isValidElement, type MouseEvent, type ReactElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + addToast: vi.fn(), +})); + +vi.mock("../ui/toast", () => ({ + toastManager: { add: testState.addToast }, +})); + +import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; + +type AnchorElement = ReactElement<{ + readonly children?: ReactNode; + readonly href?: string; + readonly onClick?: (event: MouseEvent) => void; +}>; + +const baseState: DesktopUpdateState = { + enabled: true, + status: "available", + channel: "nightly", + currentVersion: "0.0.35", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: "0.0.36-nightly.3", + downloadedVersion: null, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, +}; + +function collectAnchors(node: ReactNode, anchors: AnchorElement[] = []): AnchorElement[] { + if (Array.isArray(node)) { + for (const child of node) collectAnchors(child, anchors); + return anchors; + } + if (!isValidElement(node)) return anchors; + + const element = node as ReactElement<{ readonly children?: ReactNode }>; + if (typeof element.type === "function") { + const render = element.type as (props: unknown) => ReactNode; + return collectAnchors(render(element.props), anchors); + } + if (element.type === "a") anchors.push(element as AnchorElement); + return collectAnchors(element.props.children, anchors); +} + +function textContent(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textContent).join(""); + if (!isValidElement(node)) return ""; + const element = node as ReactElement<{ readonly children?: ReactNode }>; + return textContent(element.props.children); +} + +function renderNotes(state: DesktopUpdateState, openExternal = vi.fn().mockResolvedValue(true)) { + return SidebarUpdateReleaseNotes({ + shell: { openExternal }, + state, + tooltip: "Update available", + }); +} + +describe("SidebarUpdateReleaseNotes", () => { + beforeEach(() => { + testState.addToast.mockReset(); + }); + + it("links each preview to its exact release and labels hidden changes", () => { + const anchors = collectAnchors( + renderNotes({ + ...baseState, + releaseNotes: [ + { version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }, + { version: "0.0.36-nightly.2", items: ["Change 2"], totalItems: 2 }, + { version: "0.0.36-nightly.1", items: ["Change 1", "Earlier"], totalItems: 4 }, + ], + }), + ); + + expect(anchors.map(({ props }) => props.href)).toEqual([ + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3", + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.2", + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.1", + ]); + expect(anchors.map(({ props }) => textContent(props.children))).toEqual([ + "View release on GitHub", + "1 more change on GitHub", + "2 more changes on GitHub", + ]); + }); + + it("links omitted releases to release history", () => { + const anchors = collectAnchors( + renderNotes({ + ...baseState, + releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], + omittedReleaseCount: 1, + }), + ); + + expect(anchors.at(-1)?.props.href).toBe("https://github.com/pingdotgg/t3code/releases"); + expect(textContent(anchors.at(-1)?.props.children)).toBe("1 older release on GitHub"); + }); + + it("shows plural history text for multiple omitted releases", () => { + const anchors = collectAnchors( + renderNotes({ + ...baseState, + releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], + omittedReleaseCount: 3, + }), + ); + + expect(textContent(anchors.at(-1)?.props.children)).toBe("3 older releases on GitHub"); + }); + + it("reports a release link that fails to open", async () => { + const openExternal = vi.fn().mockResolvedValue(false); + const [anchor] = collectAnchors( + renderNotes( + { + ...baseState, + releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], + }, + openExternal, + ), + ); + const preventDefault = vi.fn(); + + anchor?.props.onClick?.({ preventDefault } as unknown as MouseEvent); + + expect(preventDefault).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(openExternal).toHaveBeenCalledWith( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3", + ); + expect(testState.addToast).toHaveBeenCalledWith({ + type: "error", + title: "Unable to open release notes", + }); + }); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx new file mode 100644 index 0000000000000..89204d1bd89b1 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -0,0 +1,120 @@ +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; +import { ExternalLinkIcon } from "lucide-react"; + +import { + getDesktopUpdateReleaseHistoryUrl, + getDesktopUpdateReleaseUrl, +} from "../desktopUpdate.logic"; +import { openDesktopUpdateReleaseNotes } from "../desktopUpdate.toast"; +import { Separator } from "../ui/separator"; + +type DesktopUpdateShell = Pick; + +function keyReleaseNoteItems(items: ReadonlyArray) { + const occurrences = new Map(); + return items.map((item) => { + const occurrence = occurrences.get(item) ?? 0; + occurrences.set(item, occurrence + 1); + return { item, key: JSON.stringify([item, occurrence]) }; + }); +} + +function ReleaseLink({ + children, + releaseUrl, + shell, +}: { + readonly children: string; + readonly releaseUrl: string; + readonly shell: DesktopUpdateShell | undefined; +}) { + return ( +
{ + event.preventDefault(); + void openDesktopUpdateReleaseNotes(shell, releaseUrl); + }} + > + {children} + + + ); +} + +export function SidebarUpdateReleaseNotes({ + shell, + state, + tooltip, +}: { + readonly shell: DesktopUpdateShell | undefined; + readonly state: DesktopUpdateState; + readonly tooltip: string; +}) { + if (state.channel !== "nightly" || state.releaseNotes.length === 0) { + return <>{tooltip}; + } + + return ( +
+
+ {state.status === "available" ? ( +
+
+ Update ready to download +
+ {state.availableVersion ? ( +
+ {state.availableVersion} +
+ ) : null} +
+ ) : ( +
{tooltip}
+ )} +
+
+ {state.releaseNotes.map((releaseNote, index) => { + const releaseUrl = getDesktopUpdateReleaseUrl(releaseNote.version); + const omittedItemCount = Math.max(0, releaseNote.totalItems - releaseNote.items.length); + const linkLabel = + omittedItemCount === 0 + ? "View release on GitHub" + : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`; + + return ( +
+ {index > 0 && } +
+

+ {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} +

+
    + {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => ( +
  • + {item} +
  • + ))} +
+ {releaseUrl ? ( + + {linkLabel} + + ) : null} +
+
+ ); + })} + {state.omittedReleaseCount > 0 ? ( +
+ + + {`${state.omittedReleaseCount} older ${state.omittedReleaseCount === 1 ? "release" : "releases"} on GitHub`} + +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts index 0c05b2d453958..f3e7818ed591f 100644 --- a/apps/web/src/state/desktopUpdate.test.ts +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -16,6 +16,7 @@ const baseState: DesktopUpdateState = { availableVersion: null, downloadedVersion: null, releaseNotes: [], + omittedReleaseCount: 0, downloadPercent: null, checkedAt: null, message: null, diff --git a/docs/user/updating.md b/docs/user/updating.md index 564d05fd431f2..ec893df8903b4 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -57,6 +57,16 @@ the warning always works. See [Running T3 Code in the Background](./background-service.md) for install, status, and removal commands. +## Nightly desktop release notes + +The desktop app shows a compact release-notes preview when a nightly update is available. Changes +appear newest first within each release. Each release links to its exact page on GitHub, even when +all changes fit in the preview. + +The preview shows up to eight changes from each of six releases. When it leaves out changes or older +releases, it shows the exact number and links to the rest. Contributor credits do not count as +changes. + ## After the Update Keep the web or desktop app open while the server restarts. The update completes only after the diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 88dc2b26b280b..609df9159247e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -209,6 +209,7 @@ export interface DesktopUpdateState { availableVersion: string | null; downloadedVersion: string | null; releaseNotes: ReadonlyArray; + omittedReleaseCount: number; downloadPercent: number | null; checkedAt: string | null; message: string | null; @@ -219,11 +220,13 @@ export interface DesktopUpdateState { export interface DesktopUpdateReleaseNote { version: string; items: ReadonlyArray; + totalItems: number; } export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ version: Schema.String, items: Schema.Array(Schema.String), + totalItems: Schema.Number, }); export const DesktopUpdateStateSchema = Schema.Struct({ @@ -237,6 +240,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ availableVersion: Schema.NullOr(Schema.String), downloadedVersion: Schema.NullOr(Schema.String), releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema), + omittedReleaseCount: Schema.Number, downloadPercent: Schema.NullOr(Schema.Number), checkedAt: Schema.NullOr(Schema.String), message: Schema.NullOr(Schema.String), From 8efd4e95fcb08478c9c6e8eee384bfca559f62cc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:18:20 -0700 Subject: [PATCH 052/136] fix(settings): sync auto-settle and other shared preferences across environments (#9147) Co-authored-by: Claude Fable 5.1 --- .../features/settings/SettingsRouteScreen.tsx | 161 ++++++++++++++---- .../components/settings/SettingsPanels.tsx | 2 + .../settings/SharedSettingsMismatchAlert.tsx | 32 ++++ .../settings/SourceControlSettings.tsx | 2 + apps/web/src/hooks/useSettings.ts | 114 ++++++++++++- docs/internals/overview.md | 5 +- docs/user/thread-sidebar.md | 16 +- packages/client-runtime/package.json | 4 + .../src/state/sharedSettings.test.ts | 107 ++++++++++++ .../src/state/sharedSettings.ts | 90 ++++++++++ 10 files changed, 489 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx create mode 100644 packages/client-runtime/src/state/sharedSettings.test.ts create mode 100644 packages/client-runtime/src/state/sharedSettings.ts diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 81f5d4e986cf2..58a2779840c34 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -19,7 +19,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { AppText as Text } from "../../components/AppText"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; @@ -36,7 +36,17 @@ import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { useEnvironments } from "../../state/environments"; +import { + DEFAULT_SERVER_SETTINGS, + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { + findSharedSettingsMismatches, + pickSharedServerSettings, +} from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -530,51 +540,136 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { - const { savedConnectionsById } = useSavedRemoteConnections(); - const connections = Object.values(savedConnectionsById).sort((left, right) => - left.environmentLabel.localeCompare(right.environmentLabel), - ); - return ( - {connections.map((connection) => ( - - ))} + ); } -function EnvironmentAutoSettleSwitch(props: { - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; -}) { - const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); - const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); +const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; + +/** + * Auto-settlement is a user preference that every server has to hold. Mobile + * has no primary environment, so the first connected environment that + * supports it is the reference value. Edits fan out to every connected + * environment, and a mismatch row lets the user push the reference out. + */ +function AutoSettleSettingsRows() { + const { environments } = useEnvironments(); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "auto-settle settings update", + label: "server settings update", reportFailure: true, }); - if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) { + + const connected = environments.filter( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.threadAutoSettlement === true, + ); + const reference = connected[0] ?? null; + const referenceSettings = reference?.serverConfig?.settings ?? null; + + const [daysDraft, setDaysDraft] = useState(null); + + if (reference === null || referenceSettings === null) { return null; } + + const writeToAll = (patch: ServerSettingsPatch) => { + for (const environment of connected) { + void updateSettings({ environmentId: environment.environmentId, input: { patch } }); + } + }; + + const mismatches = findSharedSettingsMismatches({ + primaryEnvironmentId: reference.environmentId, + primarySettings: referenceSettings, + environments: environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + connected: environment.connection.phase === "connected", + settings: environment.serverConfig?.settings ?? null, + })), + }); + + const afterDays = referenceSettings.sidebarAutoSettleAfterDays; + const commitDays = () => { + const draft = (daysDraft ?? "").trim(); + setDaysDraft(null); + // Whole-string check so "3.5" and "3days" are rejected instead of + // silently becoming 3 on every connected environment. + const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; + if ( + Number.isInteger(parsed) && + parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && + parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && + parsed !== afterDays + ) { + writeToAll({ sidebarAutoSettleAfterDays: parsed }); + } + }; + return ( - { - void updateSettings({ - environmentId: props.environmentId, - input: { patch: { sidebarAutoSettleOnMerge: value } }, - }); - }} - /> + <> + writeToAll({ sidebarAutoSettleOnMerge: value })} + /> + + writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) + } + /> + {afterDays !== null ? ( + + Days before auto-settle + + + ) : null} + {mismatches.length > 0 ? ( + + + Settings differ + + {mismatches.map((mismatch) => mismatch.label).join(", ")} + + + { + const patch = pickSharedServerSettings(referenceSettings); + for (const mismatch of mismatches) { + void updateSettings({ + environmentId: mismatch.environmentId, + input: { patch }, + }); + } + }} + className="rounded-full bg-subtle px-4 py-2 active:opacity-70" + > + Apply to all + + + ) : null} + ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 68dadd7cb6a3b..666ff4ff6ebcf 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -112,6 +112,7 @@ import { TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../../appearanceFonts"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; +import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; import { NumberField, @@ -1928,6 +1929,7 @@ export function GeneralSettingsPanel() { return ( + mismatch.label).join(", "); + return ( + + + + Settings differ on {labels}. Thread and source control preferences are meant to match on + every environment. + + + + + + ); +} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 559a90f4bf5a7..f2b0578e1c5cf 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -18,6 +18,7 @@ import { } from "@t3tools/shared/backgroundActivitySettings"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; import { cn } from "../../lib/utils"; import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; import { useEnvironmentQuery } from "../../state/query"; @@ -548,6 +549,7 @@ export function SourceControlSettingsPanel() { return ( + {isInitialScanPending ? ( <> diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 928b597f41d08..23221067b271d 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -25,6 +25,11 @@ import { type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + findSharedSettingsMismatches, + pickSharedServerSettings, + splitSharedServerPatch, +} from "@t3tools/client-runtime/state/shared-settings"; import { ensureLocalApi } from "~/localApi"; import { getThemeDefinition, @@ -35,7 +40,11 @@ import { } from "~/themePalette"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; -import { usePrimaryEnvironment } from "~/state/environments"; +import { + type EnvironmentPresentation, + useEnvironments, + usePrimaryEnvironment, +} from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { useTheme } from "./useTheme"; @@ -307,28 +316,69 @@ export function usePrimarySettings( return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); } +/** + * Whether an environment can hold every shared key right now. Gated on the + * auto-settlement capability because it is the newest of the shared keys: a + * server that has it has all of them. Older servers drop unknown keys on + * write, so a mismatch against them could never clear, and their decoded + * defaults must not be treated as real values. + */ +function supportsSharedSettings(environment: EnvironmentPresentation): boolean { + return ( + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.threadAutoSettlement === true + ); +} + +/** Environments that can receive a shared settings write right now. */ +function useConnectedEnvironmentIds(): ReadonlyArray { + const { environments } = useEnvironments(); + return useMemo( + () => + environments.filter(supportsSharedSettings).map((environment) => environment.environmentId), + [environments], + ); +} + /** * Returns an updater that routes each key to the correct backing store. * * Server keys are optimistically patched in atom-backed server state, then - * persisted via RPC. Client keys go through client persistence. + * persisted via RPC. Shared server keys (see `SHARED_SERVER_SETTING_KEYS`) + * are written to every connected environment, not only the target, so a user + * preference does not silently drift between machines. Client keys go through + * client persistence. */ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { const persistServerSettings = useAtomCommand( serverEnvironment.updateSettings, "server settings update", ); + const connectedEnvironmentIds = useConnectedEnvironmentIds(); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); if (Object.keys(serverPatch).length > 0) { - if (environmentId) { + const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); + if (environmentId && Object.keys(localPatch).length > 0) { void persistServerSettings({ environmentId, - input: { patch: serverPatch }, + input: { patch: localPatch }, }); } + if (Object.keys(sharedPatch).length > 0) { + const targets = new Set(connectedEnvironmentIds); + if (environmentId) { + targets.add(environmentId); + } + for (const targetId of targets) { + void persistServerSettings({ + environmentId: targetId, + input: { patch: sharedPatch }, + }); + } + } } if (Object.keys(clientPatch).length > 0) { persistClientSettings({ @@ -337,12 +387,66 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { }); } }, - [environmentId, persistServerSettings], + [connectedEnvironmentIds, environmentId, persistServerSettings], ); return updateSettings; } +/** + * Connected environments whose shared settings differ from the primary's, + * plus an action that writes the primary's values to all of them. Drift + * happens when an environment was offline during an edit or was changed by + * an older client. + */ +export function useSharedSettingsSync() { + const primaryEnvironment = usePrimaryEnvironment(); + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls + // back to defaults while the primary is disconnected, and "apply to all" + // must never push defaults over real values. Same for a primary too old to + // hold the shared keys: its decoded defaults are not a source of truth. + const primarySettings = + primaryEnvironment !== null && supportsSharedSettings(primaryEnvironment) + ? (primaryEnvironment.serverConfig?.settings ?? null) + : null; + const { environments } = useEnvironments(); + const persistServerSettings = useAtomCommand( + serverEnvironment.updateSettings, + "server settings update", + ); + + const mismatches = useMemo( + () => + findSharedSettingsMismatches({ + primaryEnvironmentId, + primarySettings, + environments: environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + connected: supportsSharedSettings(environment), + settings: environment.serverConfig?.settings ?? null, + })), + }), + [environments, primaryEnvironmentId, primarySettings], + ); + + const applyToAll = useCallback(() => { + if (primarySettings === null) { + return; + } + const patch = pickSharedServerSettings(primarySettings); + for (const mismatch of mismatches) { + void persistServerSettings({ + environmentId: mismatch.environmentId, + input: { patch }, + }); + } + }, [mismatches, persistServerSettings, primarySettings]); + + return { mismatches, applyToAll }; +} + export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { return useUpdateSettingsTarget(environmentId); } diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 3761cbaf87b39..1deef28b355db 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -88,7 +88,10 @@ A turn is complete when its session leaves `running` status, projected by `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later does not define turn end. -Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement. +Thread settlement is server-owned. Each server's own settings control PR and inactivity +settlement. Those keys are user preferences, so clients write them to every connected environment +(`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and warn +when a connected environment drifts. [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and once per minute, including when no client is connected. It dispatches the guarded internal `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 8240723999deb..be2c1559b1ac3 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -10,16 +10,22 @@ confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p Pinned threads still move to **Settled** when they become inactive. They also move when their pull request merges if **Auto-settle merged threads** is enabled. -Each environment owns its automatic settlement settings. The server checks them even when no web, -desktop, or mobile client is connected. By default, it settles threads after three days without +Each server stores its own copy of the automatic settlement settings and checks them even when no +web, desktop, or mobile client is connected. By default, it settles threads after three days without activity and when their pull request merges. An eligible idle thread also settles when its pull request closes. An open pull request blocks inactivity settlement. Active work, pending input, and live background work keep the thread active. T3 Code settles from a closed or merged pull request only when its timestamp is not older than the user's latest activity. If that timestamp is not available, the inactivity rule still applies. A manual un-settle also keeps the thread active. -Change these rules in **Settings > General** for the environment. A settings change affects future -settlement and does not reopen a settled thread. Settings saved by older clients on one device no -longer control this behavior. + +Change these rules in **Settings > General**. The change is written to every environment you are +connected to at that moment. An environment that is offline keeps its old value. When a connected +environment holds a different value, **Settings > General** shows a warning that names it. Choose +**Apply to all** to write your current values to every connected environment. The same applies to +the new-thread workspace mode and the source control writing style. + +A settings change affects future settlement and does not reopen a settled thread. Settings saved +by older clients on one device no longer control this behavior. When you un-settle a thread, it returns to the top of the active list so you can find it right away. Its timestamps do not change. Other threads keep their positions. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d4b4f71703050..15d5c7a8c6922 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -147,6 +147,10 @@ "types": "./src/state/session.ts", "default": "./src/state/session.ts" }, + "./state/shared-settings": { + "types": "./src/state/sharedSettings.ts", + "default": "./src/state/sharedSettings.ts" + }, "./state/shell": { "types": "./src/state/shell.ts", "default": "./src/state/shell.ts" diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts new file mode 100644 index 0000000000000..dbdf651180d32 --- /dev/null +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -0,0 +1,107 @@ +import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { + findSharedSettingsMismatches, + pickSharedServerSettings, + splitSharedServerPatch, +} from "./sharedSettings.ts"; + +const primaryId = EnvironmentId.make("env-primary"); +const laptopId = EnvironmentId.make("env-laptop"); +const boxId = EnvironmentId.make("env-box"); + +describe("splitSharedServerPatch", () => { + it("routes preference keys to the shared patch and machine keys to the local patch", () => { + const { sharedPatch, localPatch } = splitSharedServerPatch({ + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: false, + enableAgentBrowserAccess: false, + }); + expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false }); + expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); + }); +}); + +describe("pickSharedServerSettings", () => { + it("returns only the shared keys", () => { + expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([ + "defaultThreadEnvMode", + "newWorktreesStartFromOrigin", + "sidebarAutoSettleAfterDays", + "sidebarAutoSettleOnMerge", + "sourceControlWritingStyle", + ]); + }); +}); + +describe("findSharedSettingsMismatches", () => { + const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; + + it("lists connected environments whose shared settings differ", () => { + const mismatches = findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings, + environments: [ + { environmentId: primaryId, label: "Desktop", connected: true, settings: primarySettings }, + { environmentId: laptopId, label: "Laptop", connected: true, settings: primarySettings }, + { + environmentId: boxId, + label: "Remote Box", + connected: true, + settings: DEFAULT_SERVER_SETTINGS, + }, + ], + }); + expect(mismatches).toEqual([{ environmentId: boxId, label: "Remote Box" }]); + }); + + it("ignores machine-only differences", () => { + const mismatches = findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings, + environments: [ + { + environmentId: boxId, + label: "Remote Box", + connected: true, + settings: { ...primarySettings, enableAgentBrowserAccess: false }, + }, + ], + }); + expect(mismatches).toEqual([]); + }); + + it("reports nothing until the primary environment's settings are loaded", () => { + const environments = [ + { environmentId: boxId, label: "Remote Box", connected: true, settings: primarySettings }, + ]; + expect( + findSharedSettingsMismatches({ primaryEnvironmentId: null, primarySettings, environments }), + ).toEqual([]); + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings: null, + environments, + }), + ).toEqual([]); + }); + + it("skips offline environments and environments without a loaded config", () => { + const mismatches = findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings, + environments: [ + { + environmentId: laptopId, + label: "Laptop", + connected: false, + settings: DEFAULT_SERVER_SETTINGS, + }, + { environmentId: boxId, label: "Remote Box", connected: true, settings: null }, + ], + }); + expect(mismatches).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts new file mode 100644 index 0000000000000..35fa4adb46bfa --- /dev/null +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -0,0 +1,90 @@ +/** + * Shared server settings. + * + * Every server keeps its own `settings.json`, but some keys are user + * preferences that only live on the server because the server has to act on + * them (auto-settlement runs with no client attached). A user does not want + * those to differ per machine. Clients write these keys to every connected + * environment, and warn when a connected environment still holds a different + * value so the user can push their current value out. + */ +import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; +import * as Equal from "effect/Equal"; +import * as Struct from "effect/Struct"; + +/** Server keys that hold a user preference rather than machine config. */ +export const SHARED_SERVER_SETTING_KEYS = [ + "sidebarAutoSettleAfterDays", + "sidebarAutoSettleOnMerge", + "defaultThreadEnvMode", + "newWorktreesStartFromOrigin", + "sourceControlWritingStyle", +] as const satisfies ReadonlyArray; + +export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number]; + +const SHARED_KEY_SET = new Set(SHARED_SERVER_SETTING_KEYS); + +/** Split a server patch into the keys every environment should receive and the primary-only rest. */ +export function splitSharedServerPatch(patch: ServerSettingsPatch): { + sharedPatch: ServerSettingsPatch; + localPatch: ServerSettingsPatch; +} { + const sharedPatch: Record = {}; + const localPatch: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (SHARED_KEY_SET.has(key)) { + sharedPatch[key] = value; + } else { + localPatch[key] = value; + } + } + return { + sharedPatch: sharedPatch as ServerSettingsPatch, + localPatch: localPatch as ServerSettingsPatch, + }; +} + +/** The shared subset of one environment's settings, as a patch that can be written elsewhere. */ +export function pickSharedServerSettings(settings: ServerSettings): ServerSettingsPatch { + return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); +} + +export interface SharedSettingsEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly connected: boolean; + readonly settings: ServerSettings | null; +} + +/** + * Connected environments whose shared settings differ from the primary + * environment's. Offline environments are skipped: nothing can be read from + * or written to them, and the warning would never clear. With no primary + * settings loaded there is nothing to compare against, so nothing is + * reported. Callers must pass the real loaded settings, never a default + * fallback, or "apply to all" would push defaults over real values. + */ +export function findSharedSettingsMismatches(input: { + readonly primaryEnvironmentId: EnvironmentId | null; + readonly primarySettings: ServerSettings | null; + readonly environments: ReadonlyArray; +}): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { + if (input.primaryEnvironmentId === null || input.primarySettings === null) { + return []; + } + const expected = pickSharedServerSettings(input.primarySettings); + return input.environments.flatMap((environment) => { + if ( + environment.environmentId === input.primaryEnvironmentId || + !environment.connected || + environment.settings === null + ) { + return []; + } + const actual = pickSharedServerSettings(environment.settings); + return Equal.equals(actual, expected) + ? [] + : [{ environmentId: environment.environmentId, label: environment.label }]; + }); +} From 0e77fbd3d0d79eec5247e75583a04590a1785133 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:25:52 -0700 Subject: [PATCH 053/136] fix(server): prevent accidental service downgrades (#5302) --- apps/server/src/cli/service.test.ts | 170 +++++++++++++++- apps/server/src/cli/service.ts | 67 ++++++- apps/server/src/cloud/bootService.test.ts | 232 +++++++++++++++++----- apps/server/src/cloud/bootService.ts | 54 ++++- apps/server/src/cloud/serviceProtocol.ts | 14 ++ docs/user/background-service.md | 16 ++ 6 files changed, 482 insertions(+), 71 deletions(-) diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 08980faf790e8..b442d46df3689 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -1,6 +1,26 @@ -import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Terminal from "effect/Terminal"; +import { Command } from "effect/unstable/cli"; +import { afterEach, vi } from "vite-plus/test"; -import { formatServiceStatus } from "./service.ts"; +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import { + formatServiceStatus, + offerServiceDuringOnboarding, + reconcileService, + recoverServiceOnboardingOffer, + serviceCommand, +} from "./service.ts"; + +afterEach(() => vi.restoreAllMocks()); const status = { supported: true, @@ -35,3 +55,149 @@ it("explains where the service is supported", () => { "Supported on: Linux with systemd, macOS with launchd", ); }); + +it("reports a newer installed service and gives an exact-version repair command", () => { + const output = formatServiceStatus( + { ...status, current: false, installedVersion: "0.0.32-nightly.1" }, + "0.0.31", + ); + + assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)"); + assert.include(output, "npx t3@0.0.32-nightly.1 service update"); + assert.notInclude(output, "npx t3@latest service update"); +}); + +const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; + +function makeTestService(serviceStatus: BootService.BootServiceStatus) { + const installOptions: Array[0]> = []; + const service = BootService.BootService.of({ + status: Effect.succeed(serviceStatus), + install: (options) => + Effect.sync(() => { + installOptions.push(options); + return { + nodePath: "/test/node", + launcherPath: "/test/service-launcher.mjs", + baseDir: "/test/t3", + unitPath: serviceStatus.unitPath, + logPath: serviceStatus.logPath, + }; + }), + uninstall: Effect.succeed(false), + }); + return { service, installOptions }; +} + +it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => { + it.effect.each(["install", "update"] as const)( + "%s refuses a downgrade before changing the service", + (command) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service, installOptions } = makeTestService(newerServiceStatus); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + + const error = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + command, + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcessEnvironment, {}), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "BootServiceDowngradeRefusedError", + installedVersion: "999.0.0", + targetVersion: packageJson.version, + }); + expect(installOptions).toEqual([]); + }), + ); + + it.effect.each(["install", "update"] as const)("%s allows an explicit downgrade", (command) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service, installOptions } = makeTestService(newerServiceStatus); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + + yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + command, + "--base-dir", + baseDir, + "--allow-downgrade", + ]).pipe( + Effect.provideService(HostProcessEnvironment, {}), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ); + + expect(installOptions).toEqual([{ allowDowngrade: true }]); + }), + ); +}); + +it.effect.each([ + { name: "a new service", state: { ...status, installed: false, current: false } }, + { name: "an older service", state: { ...status, current: false, installedVersion: "0.0.0" } }, + { + name: "the same version", + state: { ...status, current: false, installedVersion: packageJson.version }, + }, + { name: "an unknown version", state: { ...status, current: false } }, +])("installs or repairs $name without an override", ({ state }) => + Effect.gen(function* () { + const { service, installOptions } = makeTestService(state); + + const result = yield* reconcileService().pipe( + Effect.provideService(BootService.BootService, service), + ); + + expect(result.changed).toBe(true); + expect(installOptions).toEqual([undefined]); + }), +); + +it.effect("leaves a newer service unchanged during onboarding without prompting", () => + Effect.gen(function* () { + const { service, installOptions } = makeTestService(newerServiceStatus); + const terminal = Terminal.make({ + columns: Effect.succeed(80), + rows: Effect.succeed(24), + readInput: Effect.die("Onboarding must not prompt to replace a newer service."), + readLine: Effect.die("Onboarding must not prompt to replace a newer service."), + display: () => Effect.die("Onboarding must not prompt to replace a newer service."), + }); + + const ready = yield* offerServiceDuringOnboarding.pipe( + Effect.provideService(BootService.BootService, service), + Effect.provideService(Terminal.Terminal, terminal), + Effect.provide(NodeServices.layer), + ); + + expect(ready).toBe(false); + expect(installOptions).toEqual([]); + }), +); + +it.effect("keeps onboarding successful when a newer version appears before install", () => + Effect.gen(function* () { + const ready = yield* recoverServiceOnboardingOffer( + Effect.fail( + new BootService.BootServiceDowngradeRefusedError({ + installedVersion: "999.0.0", + targetVersion: packageJson.version, + }), + ), + ); + + expect(ready).toBe(false); + }), +); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 74d220610effb..3e2e893334f0d 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -3,10 +3,11 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Terminal from "effect/Terminal"; -import { Command, GlobalFlag, Prompt } from "effect/unstable/cli"; +import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; +import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; import type * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -30,13 +31,25 @@ export type ServiceReconcileResult = }; /** Install, update, or repair the service using the CLI version running this command. */ -export const reconcileService = Effect.fn("cli.service.reconcile")(function* () { +export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { + readonly allowDowngrade?: boolean; +}) { const service = yield* BootService.BootService; const status = yield* service.status; if (status.installed && status.current) { return { changed: false, status } satisfies ServiceReconcileResult; } - const plan = yield* service.install; + if ( + status.installedVersion !== undefined && + options?.allowDowngrade !== true && + compareExactServiceVersions(packageJson.version, status.installedVersion) < 0 + ) { + return yield* new BootService.BootServiceDowngradeRefusedError({ + installedVersion: status.installedVersion, + targetVersion: packageJson.version, + }); + } + const plan = yield* service.install(options); return { changed: true, previouslyInstalled: status.installed, @@ -54,9 +67,23 @@ export function formatServiceStatus( if (!status.installed) { return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; } + const installedVersion = status.installedVersion ?? cliVersion; + if ( + !status.current && + status.installedVersion !== undefined && + compareExactServiceVersions(status.installedVersion, cliVersion) > 0 + ) { + return [ + "T3 Code service", + ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`, + ` Unit: ${status.unitPath}`, + ` Logs: ${status.logPath}`, + ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`, + ].join("\n"); + } return [ "T3 Code service", - ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`, + ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]), @@ -72,13 +99,21 @@ const runServiceCommand = Effect.fn("cli.service.run")(function* ( return yield* run.pipe(Effect.provide(bootServiceLayer(config))); }); -const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe( +const serviceReconcileFlags = { + ...projectLocationFlags, + allowDowngrade: Flag.boolean("allow-downgrade").pipe( + Flag.withDescription("Allow replacing a newer installed service with this older CLI version."), + Flag.withDefault(false), + ), +}; + +const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pipe( Command.withDescription("Install T3 Code as a background service for this user."), Command.withHandler((flags) => runServiceCommand( flags, Effect.gen(function* () { - const result = yield* reconcileService(); + const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); if (!result.changed) { yield* Console.log( `T3 Code service is already installed with t3@${packageJson.version}.`, @@ -93,7 +128,7 @@ const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe ), ); -const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe( +const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( Command.withDescription( "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.", ), @@ -101,7 +136,7 @@ const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe( runServiceCommand( flags, Effect.gen(function* () { - const result = yield* reconcileService(); + const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); if (!result.changed) { yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); return; @@ -145,7 +180,8 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( export const offerServiceDuringOnboarding = Effect.gen(function* () { const service = yield* BootService.BootService; - const { supported, installed, current } = yield* service.status; + const status = yield* service.status; + const { supported, installed, current } = status; if (!supported) { return false; } @@ -153,6 +189,17 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { yield* Console.log("T3 Code is already set up to run in the background on this machine."); return true; } + if ( + installed && + status.installedVersion !== undefined && + compareExactServiceVersions(status.installedVersion, packageJson.version) > 0 + ) { + yield* Console.log( + `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`, + ); + // This CLI cannot verify the newer service. Keep the manual fallback available. + return false; + } // A LaunchAgent starts at login and dies at logout; there is no // enable-linger equivalent on macOS. Do not promise more than that. const platform = yield* HostProcessPlatform; @@ -194,6 +241,8 @@ export const recoverServiceOnboardingOffer = ( Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), BootServiceUpdatePendingError: (error) => Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + BootServiceDowngradeRefusedError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), }), ); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index a999f81b28986..3e31ab3f63718 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -127,24 +127,34 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const commands: string[] = []; const timeouts = new Map(); - const control: { failCommand: string | undefined } = { failCommand: undefined }; + const control: { failCommand: string | undefined; stateAfterStop?: string } = { + failCommand: undefined, + }; const runner = ProcessRunner.ProcessRunner.of({ - run: (input) => - Effect.sync(() => { - const command = `${input.command} ${input.args.join(" ")}`; - commands.push(command); - timeouts.set(command, input.timeout); - return { - stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", - stderr: "", - code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), + run: Effect.fn("test.run_boot_service_command")(function* ( + input: ProcessRunner.ProcessRunInput, + ) { + const command = `${input.command} ${input.args.join(" ")}`; + commands.push(command); + timeouts.set(command, input.timeout); + if ( + control.stateAfterStop !== undefined && + (command === "systemctl --user stop t3code.service" || + command.startsWith("launchctl bootout --wait ")) + ) { + yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); + } + return { + stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", + stderr: "", + code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), }); const makeService = (environmentPath = installerPath) => BootService.make({ @@ -179,14 +189,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls", () => Effect.gen(function* () { const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); - const plan = yield* service.install; + const plan = yield* service.install(); expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); - expect((yield* service.status).current).toBe(true); + expect(yield* service.status).toMatchObject({ + current: true, + installedVersion: "1.2.3", + }); // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ protocol: SERVICE_LAUNCHER_PROTOCOL, @@ -212,10 +225,116 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect.each(["linux", "darwin"] as const)( + "reports the installed version across launcher protocols on %s", + (platform) => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(platform); + yield* service.install(); + + for (const protocol of [SERVICE_LAUNCHER_PROTOCOL - 1, SERVICE_LAUNCHER_PROTOCOL + 1]) { + yield* fs.writeFileString( + statePath, + `{"protocol":${protocol},"activeVersion":"1.2.4-nightly.1","update":{"status":"unknown"}}`, + ); + expect(yield* service.status).toMatchObject({ + current: false, + installedVersion: "1.2.4-nightly.1", + }); + } + }), + ); + + it.effect("reports an unknown version for invalid service state", () => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(); + yield* service.install(); + + for (const stateText of [ + "{", + '{"activeVersion":"latest"}', + '{"activeVersion":"1.2"}', + '{"activeVersion":123}', + ]) { + yield* fs.writeFileString(statePath, stateText); + const status = yield* service.status; + expect(status.current).toBe(false); + expect(status.installedVersion).toBeUndefined(); + } + }), + ); + + it.effect.each(["linux", "darwin"] as const)( + "preserves a newer version that finishes updating during stop on %s", + (platform) => + Effect.gen(function* () { + const { service, fs, statePath, commands, control } = yield* makeHarness(platform); + const plan = yield* service.install(); + const launcher = yield* fs.readFileString(plan.launcherPath); + const unit = yield* fs.readFileString(plan.unitPath); + control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`; + commands.length = 0; + + const error = yield* service.install().pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "BootServiceDowngradeRefusedError", + installedVersion: "1.2.4", + targetVersion: "1.2.3", + }); + expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop); + expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher); + expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); + expect( + commands.filter((command) => + command.startsWith(platform === "linux" ? "systemctl " : "launchctl "), + ), + ).toEqual( + platform === "linux" + ? ["systemctl --user stop t3code.service", "systemctl --user restart t3code.service"] + : [ + "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + `launchctl bootstrap gui/501 ${plan.unitPath}`, + ], + ); + }), + ); + + it.effect("allows an explicit downgrade", () => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(); + yield* service.install(); + yield* fs.writeFileString( + statePath, + `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.4"}`, + ); + + yield* service.install({ allowDowngrade: true }); + + expect(parseServiceState(yield* fs.readFileString(statePath))?.activeVersion).toBe("1.2.3"); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect("repairs versions with equal SemVer precedence without an override", () => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(); + yield* service.install(); + yield* fs.writeFileString( + statePath, + `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.3+previous-build"}`, + ); + + yield* service.install(); + + expect((yield* service.status).current).toBe(true); + }), + ); + it.effect("copies the launcher from the prepared pinned runtime", () => Effect.gen(function* () { const { service, fs } = yield* makeHarness("linux", true); - const plan = yield* service.install; + const plan = yield* service.install(); expect(yield* fs.readFileString(plan.launcherPath)).toBe( "export const source = 'pinned runtime';\n", @@ -226,11 +345,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("restarts an installed service when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness(); - yield* service.install; + yield* service.install(); commands.length = 0; control.failCommand = "systemctl --user daemon-reload"; - const error = yield* service.install.pipe(Effect.flip); + const error = yield* service.install().pipe(Effect.flip); expect(error._tag).toBe("BootServiceCommandError"); expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ "systemctl --user stop t3code.service", @@ -243,7 +362,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("restarts without overwriting a pending remote update", () => Effect.gen(function* () { const { service, fs, statePath, commands } = yield* makeHarness(); - yield* service.install; + yield* service.install(); // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ protocol: SERVICE_LAUNCHER_PROTOCOL - 1, @@ -256,14 +375,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }, }); yield* fs.writeFileString(statePath, pendingState); - commands.length = 0; - - expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError"); - expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); - expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ - "systemctl --user stop t3code.service", - "systemctl --user restart t3code.service", - ]); + for (const allowDowngrade of [false, true]) { + commands.length = 0; + + expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe( + "BootServiceUpdatePendingError", + ); + expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); + expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ + "systemctl --user stop t3code.service", + "systemctl --user restart t3code.service", + ]); + } }), ); @@ -271,14 +394,14 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service } = yield* makeHarness("win32"); expect((yield* service.status).supported).toBe(false); - expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError"); + expect((yield* service.install().pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError"); }), ); it.effect("installs, reports current state, and uninstalls on macOS", () => Effect.gen(function* () { const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin"); - const plan = yield* service.install; + const plan = yield* service.install(); expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe( true, @@ -291,7 +414,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => { activeVersion: "1.2.3", }); expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); - expect((yield* service.status).current).toBe(true); + expect(yield* service.status).toMatchObject({ + current: true, + installedVersion: "1.2.3", + }); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); expect(commands.some((command) => command.startsWith("npm "))).toBe(false); @@ -307,12 +433,12 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("restarts the launch agent when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness("darwin"); - yield* service.install; + yield* service.install(); const plistPath = (yield* service.status).unitPath; commands.length = 0; control.failCommand = `launchctl bootstrap gui/501 ${plistPath}`; - const error = yield* service.install.pipe(Effect.flip); + const error = yield* service.install().pipe(Effect.flip); expect(error._tag).toBe("BootServiceCommandError"); expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ "launchctl bootout --wait gui/501/com.t3tools.t3code.service", @@ -326,7 +452,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("reconstructs a launch agent search path when the installer has no PATH", () => Effect.gen(function* () { const { service, fs } = yield* makeHarness("darwin", false, ""); - const plan = yield* service.install; + const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( " PATH\n /usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin", @@ -338,7 +464,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("adds missing provider directories to a minimal installer PATH", () => Effect.gen(function* () { const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); - const plan = yield* service.install; + const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( " PATH\n /usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin", @@ -350,7 +476,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("keeps an installed launch agent current when the process PATH changes", () => Effect.gen(function* () { const { service, makeService } = yield* makeHarness("darwin"); - yield* service.install; + yield* service.install(); const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin"); expect((yield* restartedService.status).current).toBe(true); @@ -364,7 +490,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { false, "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", ); - const plan = yield* service.install; + const plan = yield* service.install(); const plist = yield* fs.readFileString(plan.unitPath); expect(plist).toContain( @@ -378,10 +504,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("ignores a bootout for an agent that is not loaded", () => Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); - yield* service.install; + yield* service.install(); control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service"; - yield* service.install; + yield* service.install(); expect((yield* service.status).current).toBe(true); }), ); @@ -389,7 +515,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("restarts without overwriting a pending remote update on macOS", () => Effect.gen(function* () { const { service, fs, statePath, commands } = yield* makeHarness("darwin"); - yield* service.install; + yield* service.install(); const plistPath = (yield* service.status).unitPath; // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ @@ -403,14 +529,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }, }); yield* fs.writeFileString(statePath, pendingState); - commands.length = 0; - - expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError"); - expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); - expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", - `launchctl bootstrap gui/501 ${plistPath}`, - ]); + for (const allowDowngrade of [false, true]) { + commands.length = 0; + + expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe( + "BootServiceUpdatePendingError", + ); + expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); + expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ + "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + `launchctl bootstrap gui/501 ${plistPath}`, + ]); + } }), ); }); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 969f45e67802d..59530498076ee 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -24,7 +24,9 @@ import { SERVICE_LAUNCHER_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, + compareExactServiceVersions, parseServiceState, + serviceStateActiveVersion, serviceStateHasPendingUpdate, type ServiceState, } from "./serviceProtocol.ts"; @@ -416,16 +418,30 @@ export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass()( + "BootServiceDowngradeRefusedError", + { + installedVersion: Schema.String, + targetVersion: Schema.String, + }, +) { + override get message(): string { + return `Refusing to replace t3@${this.installedVersion} with older t3@${this.targetVersion}. Run the command again with --allow-downgrade to continue.`; + } +} + export type BootServiceError = | BootServiceUnsupportedError | BootServiceCommandError | BootServiceInstallError - | BootServiceUpdatePendingError; + | BootServiceUpdatePendingError + | BootServiceDowngradeRefusedError; export interface BootServiceStatus { readonly supported: boolean; readonly installed: boolean; readonly current: boolean; + readonly installedVersion?: string; readonly unitPath: string; readonly logPath: string; } @@ -433,7 +449,9 @@ export interface BootServiceStatus { export class BootService extends Context.Service< BootService, { - readonly install: Effect.Effect; + readonly install: (options?: { + readonly allowDowngrade?: boolean; + }) => Effect.Effect; readonly uninstall: Effect.Effect; readonly status: Effect.Effect; } @@ -569,7 +587,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { { discard: true }, ); - const install: BootService["Service"]["install"] = Effect.gen(function* () { + const install = Effect.fn("cloud.boot_service.install")(function* (options?: { + readonly allowDowngrade?: boolean; + }) { const manager = yield* requireManager; yield* fs .makeDirectory(input.logsDir, { recursive: true }) @@ -638,11 +658,23 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* Effect.gen(function* () { if (installed) { const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); - if ( - Option.isSome(previousStateText) && - serviceStateHasPendingUpdate(previousStateText.value) - ) { - return yield* new BootServiceUpdatePendingError(); + if (Option.isSome(previousStateText)) { + if (serviceStateHasPendingUpdate(previousStateText.value)) { + return yield* new BootServiceUpdatePendingError(); + } + // A remote update can finish after the CLI checks status. Read its + // final version after the launcher stops and before changing files. + const installedVersion = serviceStateActiveVersion(previousStateText.value); + if ( + installedVersion !== undefined && + options?.allowDowngrade !== true && + compareExactServiceVersions(input.cliVersion, installedVersion) < 0 + ) { + return yield* new BootServiceDowngradeRefusedError({ + installedVersion, + targetVersion: input.cliVersion, + }); + } } } yield* fs @@ -670,7 +702,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ), ); return plan; - }).pipe(Effect.withSpan("cloud.boot_service.install")); + }); const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { const manager = yield* requireManager; @@ -704,6 +736,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { fs.readFileString(statePath).pipe(Effect.option), ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; + const installedVersion = Option.isSome(stateText) + ? serviceStateActiveVersion(stateText.value) + : undefined; const normalizeUnit = (contents: string) => detectedManager.kind === "launchd" ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") @@ -711,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { return { supported: true, installed: true, + ...(installedVersion === undefined ? {} : { installedVersion }), current: normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && launcherExists && diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index 0faf889483771..af94c9b4b5c0e 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -184,6 +184,20 @@ export function serviceStateHasPendingUpdate(value: string): boolean { } } +/** Reads the active version across launcher protocol revisions for downgrade protection. */ +export function serviceStateActiveVersion(value: string): string | undefined { + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) && + typeof parsed.activeVersion === "string" && + isExactServiceVersion(parsed.activeVersion) + ? parsed.activeVersion + : undefined; + } catch { + return undefined; + } +} + export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined { let parsed: unknown; try { diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 17de5777ba0b9..23ac07cee0dbb 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -23,6 +23,22 @@ Update or repair it: npx t3@latest service update ``` +The service uses the same T3 Code version as the CLI you run. To install a nightly or an exact +version, use that version of the CLI: + +```sh +npx t3@nightly service update +npx t3@1.2.3 service update +``` + +The install and update commands refuse to replace a newer service with an older version. Setup +through T3 Connect leaves a newer service unchanged. To downgrade, select the exact older version +and pass `--allow-downgrade`: + +```sh +npx t3@1.2.3 service update --allow-downgrade +``` + Stop it and remove it from startup: ```sh From 716069f40f000887cb01685099a16e19d9194307 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:25:55 -0700 Subject: [PATCH 054/136] fix(server): keep attachments until the command commits (#7941) --- .../Layers/OrchestrationEngine.test.ts | 11 +- .../Layers/OrchestrationEngine.ts | 9 +- .../Layers/ProjectionPipeline.test.ts | 179 +++++++++++++++++- .../Layers/ProjectionPipeline.ts | 119 +++++++++--- .../Layers/ProviderCommandReactor.test.ts | 51 ++++- .../Layers/ProviderCommandReactor.ts | 4 +- .../Services/OrchestrationEngine.ts | 11 ++ .../Services/ProjectionPipeline.ts | 11 +- .../Services/ProviderCommandReactor.ts | 1 + .../src/relay/AgentAwarenessRelay.test.ts | 2 + .../serverRuntimeStartup.reconcile.test.ts | 2 + apps/server/src/serverRuntimeStartup.test.ts | 3 + docs/internals/overview.md | 3 +- 13 files changed, 368 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index b0aa2c57d81fb..3810ee378c4a7 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -222,6 +222,7 @@ describe("OrchestrationEngine", () => { Layer.succeed(OrchestrationProjectionPipeline, { bootstrap: Effect.void, projectEvent: () => Effect.void, + projectEventDeferred: () => Effect.succeed(Effect.void), } satisfies OrchestrationProjectionPipelineShape), ), Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), @@ -1116,7 +1117,8 @@ describe("OrchestrationEngine", () => { let shouldFailRequestedProjection = true; const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = { bootstrap: Effect.void, - projectEvent: (event) => { + projectEvent: () => Effect.void, + projectEventDeferred: (event) => { if ( shouldFailRequestedProjection && event.commandId === CommandId.make("cmd-turn-start-atomic") && @@ -1130,7 +1132,7 @@ describe("OrchestrationEngine", () => { }), ); } - return Effect.void; + return Effect.succeed(Effect.void); }, }; @@ -1263,7 +1265,8 @@ describe("OrchestrationEngine", () => { let shouldFailProjection = true; const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = { bootstrap: Effect.void, - projectEvent: (event) => { + projectEvent: () => Effect.void, + projectEventDeferred: (event) => { if ( shouldFailProjection && event.commandId === CommandId.make("cmd-thread-archive-sync-fail") @@ -1276,7 +1279,7 @@ describe("OrchestrationEngine", () => { }), ); } - return Effect.void; + return Effect.succeed(Effect.void); }, }; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index f6a928fdc704e..741e0fac7195b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -224,12 +224,14 @@ const makeOrchestrationEngine = Effect.gen(function* () { .withTransaction( Effect.gen(function* () { const committedEvents: OrchestrationEvent[] = []; + const attachmentCleanups: Effect.Effect[] = []; let nextCommandReadModel = commandReadModel; for (const nextEvent of eventBases) { const savedEvent = yield* eventStore.append(nextEvent); nextCommandReadModel = yield* projectEvent(nextCommandReadModel, savedEvent); - yield* projectionPipeline.projectEvent(savedEvent); + const cleanup = yield* projectionPipeline.projectEventDeferred(savedEvent); + attachmentCleanups.push(cleanup); committedEvents.push(savedEvent); } @@ -253,6 +255,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { return { committedEvents, + attachmentCleanups, lastSequence: lastSavedEvent.sequence, nextCommandReadModel, } as const; @@ -267,6 +270,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { ); commandReadModel = committedCommand.nextCommandReadModel; + for (const cleanup of committedCommand.attachmentCleanups) { + yield* cleanup; + } for (const [index, event] of committedCommand.committedEvents.entries()) { yield* PubSub.publish(eventPubSub, event); if (index === 0) { @@ -381,6 +387,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { return { readEvents, dispatch, + subscribeDomainEvents: PubSub.subscribe(eventPubSub).pipe(Effect.map(Stream.fromSubscription)), // Each access creates a fresh PubSub subscription so that multiple // consumers (wsServer, ProviderRuntimeIngestion, CheckpointReactor, etc.) // each independently receive all domain events. diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 080ea263e447a..504fa7c52542c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2,6 +2,7 @@ import { CheckpointRef, CommandId, CorrelationId, + DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, ProjectId, @@ -874,12 +875,13 @@ it.layer( it.layer( Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")), )("OrchestrationProjectionPipeline", (it) => { - it.effect("removes unreferenced attachment files when a thread is reverted", () => + it.effect("prunes reverted attachments only after every projector commits", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; const { attachmentsDir } = yield* ServerConfig; const now = "2026-01-01T00:00:00.000Z"; const threadId = ThreadId.make("Thread Revert.Files"); @@ -1067,7 +1069,7 @@ it.layer( assert.isTrue(yield* exists(removePath)); assert.isTrue(yield* exists(otherThreadPath)); - yield* appendAndProject({ + const revertedEvent = yield* eventStore.append({ type: "thread.reverted", eventId: EventId.make("evt-revert-files-7"), aggregateKind: "thread", @@ -1083,9 +1085,75 @@ it.layer( }, }); + yield* sql` + CREATE TRIGGER fail_revert_projection + BEFORE UPDATE ON projection_state + WHEN NEW.projector = 'projection.threads' + BEGIN + SELECT RAISE(FAIL, 'forced later projector failure'); + END + `; + const projectionError = yield* projectionPipeline + .projectEvent(revertedEvent) + .pipe(Effect.flip); + assert.equal(projectionError._tag, "PersistenceSqlError"); + assert.isTrue(yield* exists(removePath)); + const rolledBackMessages = yield* sql<{ readonly messageId: string }>` + SELECT message_id AS "messageId" FROM projection_thread_messages + WHERE message_id = 'message-remove' + `; + assert.deepEqual(rolledBackMessages, [{ messageId: "message-remove" }]); + yield* sql`DROP TRIGGER fail_revert_projection`; + + const laterAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000005"; + const laterPath = path.join(attachmentsDir, `${laterAttachmentId}.png`); + yield* fileSystem.writeFileString(laterPath, "added after revert"); + const cleanup = yield* sql.withTransaction( + Effect.gen(function* () { + const cleanup = yield* projectionPipeline.projectEventDeferred(revertedEvent); + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-revert-files-later"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-revert-files-later"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-revert-files-later"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-later"), + role: "user", + text: "Later attachment", + attachments: [ + { + type: "image", + id: laterAttachmentId, + name: "later.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + assert.isTrue(yield* exists(removePath)); + // Return the cleanup effect so the caller runs it after the outer transaction commits. + // @effect-diagnostics-next-line returnEffectInGen:off + return cleanup; + }), + ); + assert.isTrue(yield* exists(removePath)); + yield* cleanup; + assert.isTrue(yield* exists(keepPath)); assert.isTrue(yield* exists(keepFilePath)); assert.isFalse(yield* exists(removePath)); + assert.isTrue(yield* exists(laterPath)); assert.isTrue(yield* exists(otherThreadPath)); }), ); @@ -3522,4 +3590,111 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { assert.isNull(detail.session); }), ); + + it.effect("cleans attachments only after the command receipt commits", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const sql = yield* SqlClient.SqlClient; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const createdAt = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-outer-rollback"); + const threadId = ThreadId.make("thread-outer-rollback"); + const cleanupFailureThreadId = ThreadId.make("thread-cleanup-failure"); + const commandId = CommandId.make("cmd-outer-rollback-delete"); + const attachmentPath = path.join( + attachmentsDir, + "thread-outer-rollback-00000000-0000-4000-8000-000000000001.png", + ); + const blockedAttachmentPath = path.join( + attachmentsDir, + "thread-cleanup-failure-00000000-0000-4000-8000-000000000001.png", + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-outer-rollback-project"), + projectId, + title: "Outer rollback project", + workspaceRoot: "/tmp/project-outer-rollback", + createdAt, + }); + for (const id of [threadId, cleanupFailureThreadId]) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-create-${id}`), + threadId: id, + projectId, + title: "Attachment cleanup thread", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + } + + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "keep this attachment"); + yield* sql` + CREATE TRIGGER fail_attachment_command_receipt + BEFORE INSERT ON orchestration_command_receipts + WHEN NEW.command_id = 'cmd-outer-rollback-delete' AND NEW.status = 'accepted' + BEGIN + SELECT RAISE(FAIL, 'forced receipt failure'); + END + `; + const deleteCommand = { type: "thread.delete", commandId, threadId } as const; + const dispatchError = yield* engine.dispatch(deleteCommand).pipe(Effect.flip); + assert.equal(dispatchError._tag, "PersistenceSqlError"); + assert.equal(yield* fileSystem.readFileString(attachmentPath), "keep this attachment"); + const rolledBackThreads = yield* sql<{ readonly deletedAt: string | null }>` + SELECT deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} + `; + assert.deepEqual(rolledBackThreads, [{ deletedAt: null }]); + const rolledBackEvents = yield* sql` + SELECT sequence FROM orchestration_events WHERE command_id = ${commandId} + `; + assert.deepEqual(rolledBackEvents, []); + const rolledBackReceipts = yield* sql` + SELECT status FROM orchestration_command_receipts WHERE command_id = ${commandId} + `; + assert.deepEqual(rolledBackReceipts, []); + yield* sql`DROP TRIGGER fail_attachment_command_receipt`; + + const result = yield* engine.dispatch(deleteCommand); + assert.isFalse(yield* exists(attachmentPath)); + const committedReceipts = yield* sql<{ + readonly status: string; + readonly resultSequence: number; + }>` + SELECT status, result_sequence AS "resultSequence" + FROM orchestration_command_receipts WHERE command_id = ${commandId} + `; + assert.deepEqual(committedReceipts, [ + { status: "accepted", resultSequence: result.sequence }, + ]); + + // Removing a nonempty directory as a file fails after the command commits. + yield* fileSystem.makeDirectory(blockedAttachmentPath); + yield* fileSystem.writeFileString(path.join(blockedAttachmentPath, "keep.txt"), "keep"); + const cleanupFailureCommandId = CommandId.make("cmd-cleanup-failure-delete"); + yield* engine.dispatch({ + type: "thread.delete", + commandId: cleanupFailureCommandId, + threadId: cleanupFailureThreadId, + }); + assert.isTrue(yield* exists(blockedAttachmentPath)); + const cleanupFailureReceipts = yield* sql<{ readonly status: string }>` + SELECT status FROM orchestration_command_receipts + WHERE command_id = ${cleanupFailureCommandId} + `; + assert.deepEqual(cleanupFailureReceipts, [{ status: "accepted" }]); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 3d1dfd9c7e392..e40f0229f4481 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1765,6 +1765,70 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }, ]; + const applyAttachmentSideEffects = Effect.fn("applyAttachmentSideEffects")( + function* (event: OrchestrationEvent, sideEffects: AttachmentSideEffects) { + if ( + sideEffects.deletedThreadIds.size === 0 && + sideEffects.prunedThreadRelativePaths.size === 0 + ) { + return; + } + + const deletedThreadIds = new Set(); + for (const threadId of sideEffects.deletedThreadIds) { + const recreatedLater = yield* eventStore.hasEventAfter({ + aggregateKind: "thread", + aggregateId: ThreadId.make(threadId), + type: "thread.created", + sequenceExclusive: event.sequence, + }); + if (!recreatedLater) { + deletedThreadIds.add(threadId); + } + } + + // Later events in the same transaction can add attachment references. + const prunedThreadRelativePaths = new Map>(); + for (const threadId of sideEffects.prunedThreadRelativePaths.keys()) { + const messages = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: ThreadId.make(threadId), + }); + prunedThreadRelativePaths.set( + threadId, + collectThreadAttachmentRelativePaths(threadId, messages), + ); + } + + yield* runAttachmentSideEffects({ deletedThreadIds, prunedThreadRelativePaths }); + }, + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig, serverConfig), + (effect, event) => + effect.pipe( + Effect.catch((cause) => + Effect.logWarning("failed to apply projected attachment side-effects", { + sequence: event.sequence, + eventType: event.type, + cause, + }), + ), + ), + ); + + const applyProjectorForEvent = Effect.fn("applyProjectorForEvent")(function* ( + projector: ProjectorDefinition, + event: OrchestrationEvent, + attachmentSideEffects: AttachmentSideEffects, + ) { + yield* projector.apply(event, attachmentSideEffects); + yield* projectionStateRepository.upsert({ + projector: projector.name, + lastAppliedSequence: event.sequence, + updatedAt: event.occurredAt, + }); + }); + const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* ( projector: ProjectorDefinition, event: OrchestrationEvent, @@ -1774,28 +1838,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti prunedThreadRelativePaths: new Map>(), }; - yield* sql.withTransaction( - projector.apply(event, attachmentSideEffects).pipe( - Effect.flatMap(() => - projectionStateRepository.upsert({ - projector: projector.name, - lastAppliedSequence: event.sequence, - updatedAt: event.occurredAt, - }), - ), - ), - ); - - yield* runAttachmentSideEffects(attachmentSideEffects).pipe( - Effect.catch((cause) => - Effect.logWarning("failed to apply projected attachment side-effects", { - projector: projector.name, - sequence: event.sequence, - eventType: event.type, - cause, - }), - ), - ); + yield* sql.withTransaction(applyProjectorForEvent(projector, event, attachmentSideEffects)); + yield* applyAttachmentSideEffects(event, attachmentSideEffects); }); const bootstrapProjector = (projector: ProjectorDefinition) => @@ -1815,19 +1859,39 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ), ); - const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => - Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { - concurrency: 1, - }).pipe( + const projectEventDeferred: OrchestrationProjectionPipelineShape["projectEventDeferred"] = + Effect.fn("projectEventDeferred")( + function* (event) { + const attachmentSideEffects: AttachmentSideEffects = { + deletedThreadIds: new Set(), + prunedThreadRelativePaths: new Map>(), + }; + yield* sql.withTransaction( + Effect.forEach( + projectors, + (projector) => applyProjectorForEvent(projector, event, attachmentSideEffects), + { concurrency: 1, discard: true }, + ), + ); + // Return the cleanup effect so the caller runs it after the outer transaction commits. + // @effect-diagnostics-next-line returnEffectInGen:off + return applyAttachmentSideEffects(event, attachmentSideEffects); + }, Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(ServerConfig, serverConfig), - Effect.asVoid, Effect.catchTag("SqlError", (sqlError) => Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)), ), ); + const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = Effect.fn( + "projectEvent", + )(function* (event) { + const cleanup = yield* projectEventDeferred(event); + yield* cleanup; + }); + const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( projectors, bootstrapProjector, @@ -1850,6 +1914,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return { bootstrap, projectEvent, + projectEventDeferred, } satisfies OrchestrationProjectionPipelineShape; }, ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ff246128c44ce..cac4ebe4dde89 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -61,6 +61,7 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { ServerActivation } from "../../serverActivation.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; @@ -150,6 +151,7 @@ describe("ProviderCommandReactor", () => { readonly requiresNewThreadForModelChange?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; + readonly serverActivation?: Effect.Effect; readonly interruptTurnEffect?: () => Effect.Effect; readonly stopSessionEffect?: () => Effect.Effect; readonly startSessionEffect?: ( @@ -395,6 +397,7 @@ describe("ProviderCommandReactor", () => { get streamDomainEvents() { return engine.streamDomainEvents; }, + subscribeDomainEvents: engine.subscribeDomainEvents, latestSequence: engine.latestSequence, } satisfies OrchestrationEngineService["Service"]; }), @@ -500,7 +503,14 @@ describe("ProviderCommandReactor", () => { } scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); + await Effect.runPromise( + reactor + .start() + .pipe( + Scope.provide(scope), + Effect.provideService(ServerActivation, input?.serverActivation), + ), + ); const drain = () => Effect.runPromise(reactor.drain); return { @@ -568,6 +578,45 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + effectIt.effect("retains a turn dispatched immediately after start until activation", () => + Effect.gen(function* () { + const activation = yield* Deferred.make(); + const started = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + serverActivation: Deferred.await(activation), + startSessionEffect: (session) => + Deferred.succeed(started, session).pipe(Effect.as(session)), + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-activation"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-before-activation"), + role: "user", + text: "Start after activation", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + expect(yield* Deferred.isDone(started)).toBe(false); + + yield* Deferred.succeed(activation, undefined); + const session = yield* Deferred.await(started); + yield* Effect.promise(() => harness.drain()); + expect(session.threadId).toBe(ThreadId.make("thread-1")); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + input: "Start after activation", + }); + }), + ); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 84d472089d8d8..37ed0d344b02b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1554,7 +1554,9 @@ const make = Effect.gen(function* () { } }); - yield* forkParked(Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent)); + // Subscribe before returning, even while event handling waits for server activation. + const domainEvents = yield* orchestrationEngine.subscribeDomainEvents; + yield* forkParked(Stream.runForEach(domainEvents, processEvent)); // The domain event stream is hot, so work pending before this reactor // starts cannot be resumed. Correlated completions only clear the request diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index a32a456840141..08f63c4764227 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -17,6 +17,7 @@ import type { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; import type * as Stream from "effect/Stream"; import type { OrchestrationDispatchError } from "../Errors.ts"; @@ -64,6 +65,16 @@ export interface OrchestrationEngineShape { */ readonly streamDomainEvents: Stream.Stream; + /** + * Acquire a domain-event subscription before starting a consumer. + * The subscription is ready when this effect returns and closes with the scope. + */ + readonly subscribeDomainEvents: Effect.Effect< + Stream.Stream, + never, + Scope.Scope + >; + /** * The latest sequence reflected in the engine's authoritative command read * model (0 if none). Used to gauge how far behind a resuming client is before diff --git a/apps/server/src/orchestration/Services/ProjectionPipeline.ts b/apps/server/src/orchestration/Services/ProjectionPipeline.ts index bb4736ca57a10..0fab7f364f095 100644 --- a/apps/server/src/orchestration/Services/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Services/ProjectionPipeline.ts @@ -26,11 +26,20 @@ export interface OrchestrationProjectionPipelineShape { /** * Project a single orchestration event into projection repositories. * - * Projectors are executed sequentially to preserve deterministic ordering. + * Projectors run sequentially in one transaction. Attachment cleanup runs + * after that transaction commits. */ readonly projectEvent: ( event: OrchestrationEvent, ) => Effect.Effect; + + /** + * Project an event inside a caller's transaction and return its attachment + * cleanup. Run the returned effect only after the outer transaction commits. + */ + readonly projectEventDeferred: ( + event: OrchestrationEvent, + ) => Effect.Effect, ProjectionRepositoryError>; } /** diff --git a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts index 65aa9949fe1d3..d36afbc1df386 100644 --- a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts @@ -19,6 +19,7 @@ export interface ProviderCommandReactorShape { * * The returned effect must be run in a scope so all worker fibers can be * finalized on shutdown. + * It subscribes before returning. Event handling waits for server activation. * * Filters orchestration domain events to provider-intent types before * processing. diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 74a4de594a151..2465052a33c7b 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -474,6 +474,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), + subscribeDomainEvents: Effect.succeed(Stream.fromQueue(events)), latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineShape; @@ -666,6 +667,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { readEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), + subscribeDomainEvents: Effect.succeed(Stream.fromQueue(events)), latestSequence: Effect.succeed(0), } satisfies OrchestrationEngineShape), Layer.succeed(ProjectionSnapshotQuery, { diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 485cd5bb08a41..4cb9cc3b82681 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -84,6 +84,7 @@ const runReconciliation = (input: { readEvents: () => Stream.empty, dispatch: input.dispatch, streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), latestSequence: Effect.succeed(0), }), Effect.provide(NodeServices.layer), @@ -289,6 +290,7 @@ it.effect("does not fail startup when the live provider session inventory cannot readEvents: () => Stream.empty, dispatch: () => Effect.die("unused"), streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), latestSequence: Effect.succeed(0), }), Effect.provide(NodeServices.layer), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 83642a956aee6..6747bfccc329a 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -181,6 +181,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), @@ -227,6 +228,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provide(NodeServices.layer), @@ -279,6 +281,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), Effect.provideService(Crypto.Crypto, { diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 1deef28b355db..65b6c1424a50f 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -73,7 +73,8 @@ processing is totally ordered. For each envelope `processEnvelope`: 3. inside one SQL transaction, appends events to the event store, applies them to the in-memory read model via [`projector.ts`][projector], projects them into persisted tables, and writes the accepted receipt; -4. after commit, swaps in the new read model and publishes committed events to subscribers. +4. after commit, swaps in the new read model, cleans up attachments, and publishes committed events + to subscribers. Attachment cleanup failures are logged and do not reject committed commands. Because persistence and projection share a transaction, the read model cannot durably disagree with the event log. On dispatch failure the engine rereads persisted events past the starting sequence and From d0b19b32e01d7abc8829c01353d759fed3d304ed Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:26:41 -0700 Subject: [PATCH 055/136] fix(claude): preview images read from the workspace (#9119) Classify Claude Read calls on image files as image_view and carry the full path through projection and both clients so the existing image renderer can load it. Co-Authored-By: Claude Fable 5.1 --- apps/mobile/src/lib/threadActivity.test.ts | 55 ++++++ apps/mobile/src/lib/threadActivity.ts | 7 + .../ActivityPayloadProjection.test.ts | 27 +++ .../ActivityPayloadProjection.ts | 20 +++ .../src/provider/Layers/ClaudeAdapter.test.ts | 162 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 34 +++- apps/web/src/session-logic.test.ts | 38 ++++ apps/web/src/session-logic.ts | 7 + .../src/work-log/presentation.test.ts | 27 ++- .../src/work-log/presentation.ts | 10 ++ 10 files changed, 384 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 136b01190e31b..5f9a65dd67562 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -504,6 +504,61 @@ describe("buildThreadFeed", () => { ); }); + it("keeps viewed image metadata while collapsing a streamed Claude Read", () => { + const turnId = TurnId.make("turn-image-read"); + const imagePath = `/workspace/${"nested folder/".repeat(16)}reference image.webp`; + const thread = makeThread({ + id: ThreadId.make("thread-image-read"), + projectId: ProjectId.make("project-1"), + title: "Image read", + activities: [ + makeActivity({ + id: EventId.make("image-read-update"), + kind: "tool.updated", + tone: "tool", + summary: "Image view", + createdAt: "2026-04-01T00:00:01.000Z", + turnId, + payload: { + toolCallId: "tool-read-image", + itemType: "image_view", + status: "inProgress", + detail: `${imagePath.slice(0, 177)}...`, + data: { imagePath }, + }, + }), + makeActivity({ + id: EventId.make("image-read-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Image view", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + toolCallId: "tool-read-image", + itemType: "image_view", + status: "completed", + detail: `${imagePath.slice(0, 177)}...`, + data: {}, + }, + }), + ], + }); + + const group = buildThreadFeed(thread)[0]; + expect(group).toMatchObject({ + type: "activity-group", + activities: [ + { + workEntry: { + itemType: "image_view", + viewedImagePath: imagePath, + }, + }, + ], + }); + }); + it("keeps MCP inputs available to expanded mobile work rows", () => { const turnId = TurnId.make("turn-mcp"); const thread = makeThread({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 367042448dac5..3047e2f5bb40f 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -88,6 +88,7 @@ export interface WorkLogEntry { turnId: TurnId | null; label: string; detail?: string; + viewedImagePath?: string; command?: string; rawCommand?: string; changedFiles?: ReadonlyArray; @@ -438,6 +439,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo } const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); + const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath); if ( !taskDetailAsLabel && payload && @@ -449,6 +451,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.detail = detail; } } + if (viewedImagePath) { + entry.viewedImagePath = viewedImagePath; + } if (commandPreview.command) { entry.command = commandPreview.command; } @@ -591,6 +596,7 @@ function mergeDerivedWorkLogEntries( ): DerivedWorkLogEntry { const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); const detail = next.detail ?? previous.detail; + const viewedImagePath = next.viewedImagePath ?? previous.viewedImagePath; const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; const toolTitle = next.toolTitle ?? previous.toolTitle; @@ -606,6 +612,7 @@ function mergeDerivedWorkLogEntries( id: previous.id, createdAt: previous.createdAt, ...(detail ? { detail } : {}), + ...(viewedImagePath ? { viewedImagePath } : {}), ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 18732fa4ea377..237a6f83d2887 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -158,6 +158,33 @@ describe("projectActivityPayload", () => { expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); }); + it("keeps full Claude Read image paths through repeated projection", () => { + const imagePath = `/workspace/${"nested folder/".repeat(16)}reference image.webp`; + const projected = projectActivityPayload( + activity({ + itemType: "dynamic_tool_call", + detail: 'Read: {"file_path":"truncated..."}', + data: { + toolName: "Read", + input: { file_path: imagePath }, + result: { content: "Image Size: 1280x720." }, + }, + }), + ); + const projectedAgain = projectActivityPayload(projected); + + expect(projected.payload).toMatchObject({ data: { imagePath } }); + expect(projectedAgain.payload).toMatchObject({ data: { imagePath } }); + + const textRead = projectActivityPayload( + activity({ + itemType: "dynamic_tool_call", + data: { toolName: "Read", input: { file_path: "/workspace/src/index.ts" } }, + }), + ); + expect(textRead.payload).not.toMatchObject({ data: { imagePath: expect.anything() } }); + }); + it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 0b1cb15d3dbc3..5a4a3d9a275cc 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -3,6 +3,7 @@ import type { OrchestrationThreadActivity, OrchestrationThreadDetailSnapshot, } from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -143,6 +144,21 @@ function projectCommandValue(data: Record): unknown { return undefined; } +function projectViewedImagePath(data: Record): string | undefined { + const directPath = asTrimmedString(data.imagePath); + if (directPath && isWorkspaceImagePreviewPath(directPath)) { + return directPath; + } + + const toolName = asTrimmedString(data.toolName)?.toLowerCase(); + if (toolName !== "read" && toolName !== "read file") { + return undefined; + } + const input = asRecord(data.input); + const inputPath = asTrimmedString(input?.file_path) ?? asTrimmedString(input?.path); + return inputPath && isWorkspaceImagePreviewPath(inputPath) ? inputPath : undefined; +} + function summarizeToolTextOutput(value: string): string | null { let meaningfulLineCount = 0; let offset = 0; @@ -374,6 +390,10 @@ export function projectActivityPayload( if (command !== undefined) { projectedData.command = command; } + const imagePath = projectViewedImagePath(data); + if (imagePath) { + projectedData.imagePath = imagePath; + } const changedFiles: string[] = []; collectChangedFiles(data, changedFiles, new Set(), 0); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d629394acad7a..759a16410931f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -46,6 +46,7 @@ import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../ import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); +const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); // Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`. class ClaudeAdapter extends Context.Service()( @@ -1434,6 +1435,167 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("classifies only streamed Read image inputs as image views", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "inspect both files", + attachments: [], + }); + + const imagePath = `/workspace/${"nested folder/".repeat(16)}reference image.webp`; + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-read-image", + uuid: "read-image-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-read-image", + name: "Read", + input: {}, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-read-image", + uuid: "read-image-input", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: encodeUnknownJsonString({ file_path: imagePath }), + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "user", + session_id: "sdk-session-read-image", + uuid: "read-image-result", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-read-image", + content: "Image Size: 1280x720.", + }, + ], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-read-image", + uuid: "read-text-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-read-text", + name: "Read", + input: { file_path: "/workspace/src/index.ts" }, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "user", + session_id: "sdk-session-read-image", + uuid: "read-text-result", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-read-text", + content: "export {};", + }, + ], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-read-image", + uuid: "read-image-turn-result", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const imageEvents = runtimeEvents.filter( + ( + event, + ): event is Extract< + ProviderRuntimeEvent, + { type: "item.started" | "item.updated" | "item.completed" } + > => + (event.type === "item.started" || + event.type === "item.updated" || + event.type === "item.completed") && + String(event.itemId) === "tool-read-image", + ); + assert.deepEqual( + imageEvents.map((event) => [event.type, event.payload.itemType]), + [ + ["item.started", "dynamic_tool_call"], + ["item.updated", "image_view"], + ["item.updated", "image_view"], + ["item.completed", "image_view"], + ], + ); + for (const event of imageEvents.slice(1)) { + assert.equal(event.payload.detail, imagePath); + assert.equal( + (event.payload.data as { input?: { file_path?: string } } | undefined)?.input?.file_path, + imagePath, + ); + } + + const textEvents = runtimeEvents.filter( + ( + event, + ): event is Extract< + ProviderRuntimeEvent, + { type: "item.started" | "item.updated" | "item.completed" } + > => + (event.type === "item.started" || + event.type === "item.updated" || + event.type === "item.completed") && + String(event.itemId) === "tool-read-text", + ); + assert.deepEqual( + textEvents.map((event) => event.payload.itemType), + ["dynamic_tool_call", "dynamic_tool_call", "dynamic_tool_call"], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to a default plan step label for blank TodoWrite content", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c0e24231643af..07739146b0749 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -20,6 +20,7 @@ import { type ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { ApprovalRequestId, type CanonicalItemType, @@ -713,8 +714,27 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef }; } -function classifyToolItemType(toolName: string): CanonicalItemType { +function readToolImagePath(toolName: string, input: Record): string | undefined { + const normalized = toolName.trim().toLowerCase(); + if (normalized !== "read" && normalized !== "read file") { + return undefined; + } + const pathValue = input.file_path ?? input.path; + if (typeof pathValue !== "string") { + return undefined; + } + const path = pathValue.trim(); + return path.length > 0 && isWorkspaceImagePreviewPath(path) ? path : undefined; +} + +function classifyToolItemType( + toolName: string, + input: Record = {}, +): CanonicalItemType { const normalized = toolName.toLowerCase(); + if (readToolImagePath(toolName, input)) { + return "image_view"; + } if (normalized.includes("agent")) { return "collab_agent_tool_call"; } @@ -1166,6 +1186,11 @@ function workflowAgentStatus(entry: ClaudeWorkflowAgentEntry): RuntimeTaskStatus } function summarizeToolRequest(toolName: string, input: Record): string { + const imagePath = readToolImagePath(toolName, input); + if (imagePath) { + return imagePath; + } + const commandValue = input.command ?? input.cmd; const command = typeof commandValue === "string" ? commandValue : undefined; if (command && command.trim().length > 0) { @@ -2529,9 +2554,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const partialInputJson = tool.partialInputJson + event.delta.partial_json; const parsedInput = tryParseJsonRecord(partialInputJson); + const itemType = parsedInput + ? classifyToolItemType(tool.toolName, parsedInput) + : tool.itemType; const detail = parsedInput ? summarizeToolRequest(tool.toolName, parsedInput) : tool.detail; let nextTool: ToolInFlight = { ...tool, + itemType, + title: titleForTool(itemType), partialInputJson, ...(parsedInput ? { input: parsedInput } : {}), ...(detail ? { detail } : {}), @@ -2636,11 +2666,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const toolName = block.name; - const itemType = classifyToolItemType(toolName); const toolInput = typeof block.input === "object" && block.input !== null ? (block.input as Record) : {}; + const itemType = classifyToolItemType(toolName, toolInput); const itemId = block.id; const detail = summarizeToolRequest(toolName, toolInput); const inputFingerprint = diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index dc32f84b1e5d6..a0d24d78adf12 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1653,6 +1653,44 @@ describe("deriveWorkLogEntries", () => { }); }); + it("keeps viewed image metadata while collapsing a streamed Claude Read", () => { + const imagePath = `/workspace/${"nested folder/".repeat(16)}reference image.webp`; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "image-read-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Image view", + payload: { + toolCallId: "tool-read-image", + itemType: "image_view", + detail: `${imagePath.slice(0, 177)}...`, + data: { imagePath }, + }, + }), + makeActivity({ + id: "image-read-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Image view", + payload: { + toolCallId: "tool-read-image", + itemType: "image_view", + detail: `${imagePath.slice(0, 177)}...`, + data: {}, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "image-read-complete", + itemType: "image_view", + viewedImagePath: imagePath, + }); + }); + it("does not use command stdout as the detail when Cursor omits the command input", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 6d853dc3ea1e7..519f888ee528e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -73,6 +73,7 @@ export interface WorkLogEntry { toolCallId?: string; label: string; detail?: string; + viewedImagePath?: string; command?: string; rawCommand?: string; changedFiles?: ReadonlyArray; @@ -924,9 +925,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); + const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath); if (detail) { entry.detail = detail; } + if (viewedImagePath) { + entry.viewedImagePath = viewedImagePath; + } if (commandPreview.command) { entry.command = commandPreview.command; } @@ -1156,6 +1161,7 @@ function mergeDerivedWorkLogEntries( ): DerivedWorkLogEntry { const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); const detail = next.detail ?? previous.detail; + const viewedImagePath = next.viewedImagePath ?? previous.viewedImagePath; const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; const toolTitle = next.toolTitle ?? previous.toolTitle; @@ -1169,6 +1175,7 @@ function mergeDerivedWorkLogEntries( ...previous, ...next, ...(detail ? { detail } : {}), + ...(viewedImagePath ? { viewedImagePath } : {}), ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index b4abd4c40199b..1d2cedee544cb 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vite-plus/test"; import { ThreadId } from "@t3tools/contracts"; -import { resolveViewedImageAsset, workEntryViewedImagePath } from "./presentation.js"; +import { + resolveViewedImageAsset, + toolGroupAction, + workEntryViewedImagePath, +} from "./presentation.js"; describe("workEntryViewedImagePath", () => { const entry = { label: "Read", tone: "tool" } as const; @@ -19,6 +23,14 @@ describe("workEntryViewedImagePath", () => { detail: "C:\\workspace\\a.webp", }), ).toBe("C:\\workspace\\a.webp"); + expect( + workEntryViewedImagePath({ + ...entry, + itemType: "dynamic_tool_call", + detail: 'Read: {"file_path":"truncated..."}', + viewedImagePath: " /workspace/reference image.webp ", + }), + ).toBe("/workspace/reference image.webp"); }); it("rejects non-image, multi-line, and non-read details", () => { @@ -32,6 +44,19 @@ describe("workEntryViewedImagePath", () => { }); }); +describe("toolGroupAction", () => { + it("groups legacy Claude image reads with other reads", () => { + expect( + toolGroupAction({ + label: "Tool call", + tone: "tool", + itemType: "dynamic_tool_call", + viewedImagePath: "/workspace/reference.png", + }), + ).toBe("read"); + }); +}); + describe("resolveViewedImageAsset", () => { const threadId = ThreadId.make("thread-1"); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 2d5e6ebfc867d..f1334754c72e2 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -20,6 +20,7 @@ export interface WorkLogPresentationEntry { readonly tone: "thinking" | "tool" | "info" | "error"; readonly command?: string; readonly detail?: string; + readonly viewedImagePath?: string; readonly changedFiles?: ReadonlyArray; readonly itemType?: ToolLifecycleItemType; readonly requestKind?: string; @@ -68,6 +69,7 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio if ( entry.requestKind === "file-read" || entry.itemType === "image_view" || + entry.viewedImagePath !== undefined || (entry.itemType === "dynamic_tool_call" && entry.toolTitle?.trim().toLowerCase() === "read file") ) { @@ -89,6 +91,14 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio } export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null { + const viewedImagePath = entry.viewedImagePath?.trim(); + if ( + viewedImagePath !== undefined && + !/[\r\n]/.test(viewedImagePath) && + isWorkspaceImagePreviewPath(viewedImagePath) + ) { + return viewedImagePath; + } const detail = entry.detail?.trim(); return toolGroupAction(entry) === "read" && detail !== undefined && From cdbf324aa043b1bc5705159127d2b55f186d1b9b Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:43:57 -0700 Subject: [PATCH 056/136] fix(web): keep generated muted foreground dimmer than entered text (#9113) --- apps/web/src/themePalette.test.ts | 3 +++ apps/web/src/themePalette.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 562ebc2637646..38e393608136f 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -156,6 +156,9 @@ describe("theme files", () => { expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.textMuted, colors.canvas)).toBeLessThan(5.5); expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeLessThan( + contrastRatio(colors.text, colors.muted), + ); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeLessThan( contrastRatio(colors.text, colors.surfaceRaised), diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 458d4539e2702..f7dd111705907 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -916,7 +916,7 @@ export function createVividThemeColors( themeOklchToThemeColor( solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker"), ); - const mutedForeground = foregroundOn(mutedRgb); + const mutedForeground = themeRgbToThemeColor(readableThemeText(mutedRgb, textRgb, 1, 4.6)); const placeholder = themeRgbToThemeColor(readableThemeText(surfaceRaisedRgb, textRgb, 1, 4.6)); const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) }; From 083d4de5b0fd33efa28363546b6ed89e191c5200 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:46:14 -0700 Subject: [PATCH 057/136] fix(clients): stop repeating expanded commands (#9120) Expanded command rows use a neutral Command label and keep the full command plus real output in the detail area. Claude Bash results now cross the wire as a bounded summary. Truncated Claude echoes and ACP echoes without a kind are dropped as synthetic. Co-Authored-By: Claude Fable 5.1 --- .../src/features/threads/thread-work-log.tsx | 5 +- apps/mobile/src/lib/threadActivity.test.ts | 216 ++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 57 +++-- .../ActivityPayloadProjection.test.ts | 18 +- .../ActivityPayloadProjection.ts | 8 +- .../test/ActivityPayloadProjection.test.ts | 69 +++++- .../src/components/chat/MessagesTimeline.tsx | 7 +- .../src/session-logic.command-output.test.ts | 87 +++++++ apps/web/src/session-logic.ts | 107 ++------- .../src/work-log/presentation.test.ts | 85 +++++++ .../src/work-log/presentation.ts | 141 ++++++++++++ 11 files changed, 685 insertions(+), 115 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index d26f8976b9ca0..085c5580a9f90 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -338,7 +338,8 @@ export function ThreadWorkLog(props: { const canExpand = row.canExpand; const fullDetail = expanded ? row.getFullDetail() : null; const viewedImagePath = workEntryViewedImagePath(row.workEntry); - const displayText = row.detail ?? row.summary; + const previewText = row.detail ?? row.summary; + const displayText = expanded && row.workEntry.command?.trim() ? "Command" : previewText; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; const failed = row.status === "failure"; const showIcon = !row.groupedToolDetail || iconIsDestructive || failed; @@ -352,7 +353,7 @@ export function ThreadWorkLog(props: { > { + it("keeps long Claude commands expandable without repeating them in full detail", () => { + const command = `printf 'first line\nsecond line'\n&& printf done`; + const thread = makeThread({ + id: ThreadId.make("thread-long-command"), + projectId: ProjectId.make("project-1"), + title: "Long command", + activities: [ + makeActivity({ + id: EventId.make("long-command"), + kind: "tool.completed", + tone: "tool", + summary: "Command run", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Command run", + detail: `Bash: ${command}`, + data: { toolName: "Bash", command }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row).toMatchObject({ detail: command, canExpand: true }); + expect(row?.getFullDetail()).toBe(command); + expect(row?.getCopyText()).toBe(`Command run\n${command}`); + }); + + it("keeps command output when it equals the displayed command", () => { + const command = "printf hello"; + const thread = makeThread({ + id: ThreadId.make("thread-matching-command-output"), + projectId: ProjectId.make("project-1"), + title: "Matching output", + activities: [ + makeActivity({ + id: EventId.make("matching-command-output"), + kind: "tool.completed", + tone: "tool", + summary: "Command run", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Command run", + detail: `Bash: ${command}`, + data: { toolName: "Bash", command, rawOutput: { content: command } }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row?.detail).toBe(command); + expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); + expect(row?.getCopyText()).toBe(`Command run\n${command}\n\n${command}`); + }); + + it("keeps OpenCode detail-only output when it equals the command", () => { + const command = "printf hello"; + const thread = makeThread({ + id: ThreadId.make("thread-opencode-detail-output"), + projectId: ProjectId.make("project-1"), + title: "OpenCode detail output", + activities: [ + makeActivity({ + id: EventId.make("opencode-detail-output"), + kind: "tool.completed", + tone: "tool", + summary: "bash", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "bash", + detail: command, + data: { command }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row?.workEntry.detail).toBe(command); + expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); + }); + + it("drops a truncated Claude echo of a long command", () => { + const command = `git add -A && git commit -m "${"x".repeat(200)}"`; + const thread = makeThread({ + id: ThreadId.make("thread-truncated-echo"), + projectId: ProjectId.make("project-1"), + title: "Truncated echo", + activities: [ + makeActivity({ + id: EventId.make("truncated-echo"), + kind: "tool.completed", + tone: "tool", + summary: "Command run", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Command run", + detail: `Bash: ${command}`.slice(0, 177) + "...", + data: { toolName: "Bash", command }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row?.workEntry.detail).toBeUndefined(); + expect(row?.getFullDetail()).toBe(command); + }); + + it("drops an ACP command echo when the update omits the tool kind", () => { + const command = "pnpm test"; + const thread = makeThread({ + id: ThreadId.make("thread-acp-no-kind"), + projectId: ProjectId.make("project-1"), + title: "ACP no kind", + activities: [ + makeActivity({ + id: EventId.make("acp-no-kind"), + kind: "tool.completed", + tone: "tool", + summary: "Terminal", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Terminal", + detail: command, + data: { toolCallId: "tool-1", command }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row?.workEntry.detail).toBeUndefined(); + expect(row?.getFullDetail()).toBe(command); + }); + + it("drops ACP command metadata when detail only repeats the command", () => { + const command = "printf hello"; + const thread = makeThread({ + id: ThreadId.make("thread-acp-command-detail"), + projectId: ProjectId.make("project-1"), + title: "ACP command detail", + activities: [ + makeActivity({ + id: EventId.make("acp-command-detail"), + kind: "tool.completed", + tone: "tool", + summary: "Terminal", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Terminal", + detail: command, + data: { kind: "execute", command }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row?.workEntry.detail).toBeUndefined(); + expect(row?.getFullDetail()).toBe(command); + }); + + it("does not show command output when the command input is missing", () => { + const thread = makeThread({ + id: ThreadId.make("thread-command-without-input"), + projectId: ProjectId.make("project-1"), + title: "Missing command input", + activities: [ + makeActivity({ + id: EventId.make("command-without-input"), + kind: "tool.completed", + tone: "tool", + summary: "Command run", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Command run", + data: { rawOutput: { content: "output without command metadata" } }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + expect(group.activities[0]?.detail).toBeNull(); + expect(group.activities[0]?.getFullDetail()).toBeNull(); + }); + it("keeps setup failures visible without routine setup notices before or after a turn", () => { const thread = makeThread({ id: ThreadId.make("thread-worktree-setup"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 3047e2f5bb40f..36646be771ff2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -14,6 +14,8 @@ import type { } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { + commandDetailRepeatsCommand, + extractCommandOutputText, isWorktreeSetupActivity, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, @@ -440,16 +442,23 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath); - if ( - !taskDetailAsLabel && - payload && - typeof payload.detail === "string" && - payload.detail.length > 0 - ) { + const commandOutput = commandPreview.command ? extractCommandOutputText(payload?.data) : null; + const output = commandOutput ? stripTrailingExitCode(commandOutput).output : null; + if (!taskDetailAsLabel && output) { + entry.detail = output; + } else if (!taskDetailAsLabel && typeof payload?.detail === "string") { const detail = stripTrailingExitCode(payload.detail).output; - if (detail) { - entry.detail = detail; - } + const data = asRecord(payload.data); + const repeatsCommand = + detail !== null && + commandDetailRepeatsCommand({ + detail, + command: commandPreview.command, + rawCommand: commandPreview.rawCommand, + toolName: data?.toolName, + data, + }); + if (detail && !repeatsCommand) entry.detail = detail; } if (viewedImagePath) { entry.viewedImagePath = viewedImagePath; @@ -757,20 +766,18 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { const blocks: string[] = []; - const appendUniqueBlock = (value: string | null | undefined) => { + const appendBlock = (value: string | null | undefined) => { const trimmed = value?.trim(); - if (trimmed && !blocks.includes(trimmed)) { - blocks.push(trimmed); - } + if (trimmed && (entry.command || !blocks.includes(trimmed))) blocks.push(trimmed); }; if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) { - appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`); + appendBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`); } - appendUniqueBlock(entry.rawCommand ?? entry.command); - appendUniqueBlock(entry.detail); + appendBlock(entry.rawCommand ?? entry.command); + appendBlock(entry.detail); if ((entry.changedFiles?.length ?? 0) > 0) { - appendUniqueBlock(entry.changedFiles!.join("\n")); + appendBlock(entry.changedFiles!.join("\n")); } return blocks.length > 0 ? blocks.join("\n\n") : null; @@ -1765,13 +1772,21 @@ export function buildThreadFeed( const summary = workEntryHeading(entry); const detail = workEntryPreview(entry); const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); - const getCopyText = memoizeValue(() => - [summary, detail, getFullDetail()] + const getCopyText = memoizeValue(() => { + const fullDetail = getFullDetail(); + if (entry.command) { + const normalizedCommand = + entry.rawCommand && summary.trim() !== entry.command.trim() ? entry.command : null; + return [summary, normalizedCommand, fullDetail ?? entry.command] + .filter((value): value is string => Boolean(value)) + .join("\n"); + } + return [summary, detail, fullDetail] .filter((value, index, values): value is string => { return Boolean(value) && values.indexOf(value) === index; }) - .join("\n"), - ); + .join("\n"); + }); return { type: "activity", id: entry.id, diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 237a6f83d2887..bf09ed959e178 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -119,7 +119,7 @@ describe("projectActivityPayload", () => { expect(JSON.stringify(acp.payload).length).toBeLessThan(500); }); - it("normalizes Claude and OpenCode command inputs before slimming provider data", () => { + it("keeps bounded Claude command input and result summaries", () => { const claude = projectActivityPayload( activity({ itemType: "command_execution", @@ -127,7 +127,13 @@ describe("projectActivityPayload", () => { data: { toolName: "Bash", input: { command: "vp test run" }, - result: { content: "x".repeat(5_000) }, + result: { + type: "tool_result", + content: [ + { type: "text", text: "tests passed" }, + { type: "text", text: "x".repeat(5_000) }, + ], + }, }, }), ); @@ -148,13 +154,17 @@ describe("projectActivityPayload", () => { expect(claude.payload).toMatchObject({ toolCallId: "claude-call-1", - data: { command: "vp test run" }, + data: { + toolName: "Bash", + command: "vp test run", + rawOutput: { content: "tests passed" }, + }, }); expect(openCode.payload).toMatchObject({ toolCallId: "opencode-call-1", data: { command: "vp lint" }, }); - expect(JSON.stringify(claude.payload).length).toBeLessThan(200); + expect(JSON.stringify(claude.payload).length).toBeLessThan(250); expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); }); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 5a4a3d9a275cc..98294e63b35c6 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -408,8 +408,14 @@ export function projectActivityPayload( if ("kind" in data) { projectedData.kind = data.kind; } + if ("toolName" in data) { + projectedData.toolName = data.toolName; + } - const rawOutput = projectRawOutput(data.rawOutput) ?? projectAcpContent(data.content); + const rawOutput = + projectRawOutput(data.rawOutput) ?? + projectAcpContent(data.content) ?? + (payload.itemType === "command_execution" ? summarizeMcpResult(data.result) : undefined); if (rawOutput) { projectedData.rawOutput = rawOutput; } diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 7ac564bb41f33..2fd7a3092bebc 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -184,6 +184,52 @@ describe("projectActivityPayload", () => { }); }); + it("projects a Claude Bash result for the web and mobile expanded rows", () => { + const command = `printf 'first line\nsecond line'\n&& printf done`; + const source: OrchestrationThreadActivity = { + ...makeActivity("claude-bash", "command_execution", {}), + summary: "Command run", + payload: { + itemType: "command_execution", + title: "Command run", + detail: `Bash: ${command}`, + status: "completed", + data: { + toolName: "Bash", + input: { command }, + result: { + type: "tool_result", + tool_use_id: "toolu_1", + content: [ + { type: "text", text: "first output line" }, + { type: "text", text: "x".repeat(5_000) }, + ], + }, + }, + }, + }; + const projected = projectActivityPayload(source); + + expect(projected.payload).toMatchObject({ + data: { + toolName: "Bash", + command, + rawOutput: { content: "first output line" }, + }, + }); + + const [webEntry] = deriveWorkLogEntries([projected]); + expect(webEntry).toMatchObject({ command, detail: "first output line" }); + + const [mobileGroup] = buildThreadFeed(makeThread([projected])); + expect(mobileGroup?.type).toBe("activity-group"); + if (mobileGroup?.type !== "activity-group") return; + const [mobileRow] = mobileGroup.activities; + expect(mobileRow).toMatchObject({ detail: command, canExpand: true }); + expect(mobileRow?.getFullDetail()).toBe(`${command}\n\nfirst output line`); + expect(mobileRow?.getCopyText()).toBe(`Command run\n${command}\n\nfirst output line`); + }); + it("slims MCP tool data to the fields the expanded row renders", () => { expect(projectActivityPayload(fixtures[4]!).payload).toEqual({ itemType: "mcp_tool_call", @@ -201,9 +247,30 @@ describe("projectActivityPayload", () => { }); }); - it("keeps current web and mobile derived output identical for every tool item type", () => { + it("keeps current web and mobile derived fields for every tool item type", () => { for (const activity of fixtures) { const projected = projectActivityPayload(activity); + if (activity === fixtures[0]) { + expect(deriveWorkLogEntries([projected])).toMatchObject([ + { + command: "pnpm test", + rawCommand: 'bash -lc "pnpm test"', + detail: "first useful line", + }, + ]); + expect(comparableThreadFeed([projected])).toMatchObject([ + { + type: "activity-group", + activities: [ + { + detail: "pnpm test", + fullDetail: 'bash -lc "pnpm test"\n\nfirst useful line', + }, + ], + }, + ]); + continue; + } if (activity === fixtures[4]) { // MCP is the one deliberate difference: the expanded row's toolData // loses result bulk but keeps the rendered identity fields. diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6433b82dfc829..7465b0f73bde7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2448,7 +2448,8 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); const entryIconName = showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry); - const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); + const previewText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); + const displayText = expanded && workEntry.command?.trim() ? "Command" : previewText; const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = @@ -2483,8 +2484,8 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { : "text-foreground/80"; const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; const accessibleDisplayText = showFailedIndicator - ? `${displayText}, tool call failed` - : displayText; + ? `${previewText}, tool call failed` + : previewText; const rowToggleProps = canExpand ? { role: "button" as const, diff --git a/apps/web/src/session-logic.command-output.test.ts b/apps/web/src/session-logic.command-output.test.ts index 570629046a60a..406b888505d03 100644 --- a/apps/web/src/session-logic.command-output.test.ts +++ b/apps/web/src/session-logic.command-output.test.ts @@ -66,6 +66,93 @@ describe("deriveWorkLogEntries command output", () => { }); }); + it("keeps command output that equals the command text", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("matching-output", { + itemType: "command_execution", + title: "Ran command", + detail: "printf hello", + data: { + command: "printf hello", + rawOutput: { content: "printf hello" }, + }, + }), + ]); + + expect(entry).toMatchObject({ + command: "printf hello", + detail: "printf hello", + }); + }); + + it("keeps OpenCode detail-only output when it equals the command", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("opencode-detail-output", { + itemType: "command_execution", + title: "bash", + detail: "printf hello", + data: { command: "printf hello" }, + }), + ]); + + expect(entry).toMatchObject({ + command: "printf hello", + detail: "printf hello", + }); + }); + + it("drops a Claude tool-name detail when there is no output", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("claude-no-output", { + itemType: "command_execution", + title: "Command run", + detail: "Bash: printf hello", + data: { + toolName: "Bash", + command: "printf hello", + }, + }), + ]); + + expect(entry?.command).toBe("printf hello"); + expect(entry?.detail).toBeUndefined(); + }); + + it("drops a truncated Claude tool-name detail for a long command", () => { + const command = `git add -A && git commit -m "${"x".repeat(200)}"`; + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("claude-long-command", { + itemType: "command_execution", + title: "Command run", + detail: `Bash: ${command}`.slice(0, 177) + "...", + data: { + toolName: "Bash", + command, + }, + }), + ]); + + expect(entry?.command).toBe(command); + expect(entry?.detail).toBeUndefined(); + }); + + it("drops an ACP command echo when the update omits the tool kind", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("acp-no-kind", { + itemType: "command_execution", + title: "Terminal", + detail: "pnpm test", + data: { + toolCallId: "tool-1", + command: "pnpm test", + }, + }), + ]); + + expect(entry?.command).toBe("pnpm test"); + expect(entry?.detail).toBeUndefined(); + }); + it("drops duplicated command detail when the command has no output", () => { const [entry] = deriveWorkLogEntries([ makeCommandActivity("empty-command", { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 519f888ee528e..2bc3153b9f486 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -2,7 +2,11 @@ import * as Option from "effect/Option"; import * as Arr from "effect/Array"; import * as Schema from "effect/Schema"; import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; -import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation"; +import { + commandDetailRepeatsCommand, + extractCommandOutputText, + isWorktreeSetupActivity, +} from "@t3tools/client-runtime/work-log/presentation"; import { ApprovalRequestId, isToolLifecycleItemType, @@ -1514,68 +1518,9 @@ function summarizeToolRawOutput(payload: Record | null): string return null; } -function extractAcpTextContent(value: unknown): string | null { - if (!Array.isArray(value)) { - return null; - } - - const chunks: string[] = []; - for (const entryValue of value) { - const entry = asRecord(entryValue); - if (entry?.type !== "content") { - continue; - } - const content = asRecord(entry.content); - if (content?.type !== "text") { - continue; - } - const text = asTrimmedString(content.text); - if (text) { - chunks.push(text); - } - } - - return chunks.length > 0 ? chunks.join("\n") : null; -} - function extractToolOutput(payload: Record | null): string | null { - const data = asRecord(payload?.data); - const item = asRecord(data?.item); - const itemResult = asRecord(item?.result); - const rawOutput = asRecord(data?.rawOutput); - - const outputStreams: string[] = []; - const stdout = asTrimmedString(rawOutput?.stdout); - const stderr = asTrimmedString(rawOutput?.stderr); - if (stdout) { - outputStreams.push(stdout); - } - if (stderr) { - outputStreams.push(stderr); - } - - const candidates: unknown[] = [ - item?.aggregatedOutput, - itemResult?.content, - data?.rawOutput, - rawOutput?.content, - outputStreams.length > 0 ? outputStreams.join("\n") : null, - rawOutput?.output, - extractAcpTextContent(data?.content), - ]; - - for (const candidate of candidates) { - const text = asTrimmedString(candidate); - if (!text) { - continue; - } - const output = stripTrailingExitCode(text).output; - if (output) { - return output; - } - } - - return null; + const output = extractCommandOutputText(payload?.data); + return output ? stripTrailingExitCode(output).output : null; } function isCommandToolDetail(payload: Record | null, heading: string): boolean { @@ -1603,32 +1548,28 @@ function extractToolDetail( ? extractToolCommand(payload) : { command: null, rawCommand: null }; const command = commandPreview.command; - const normalizedCommand = normalizePreviewForComparison(command); - const normalizedRawCommand = normalizePreviewForComparison(commandPreview.rawCommand); - if ( - detail && - normalizedHeading !== normalizedDetail && - (!commandTool || - (normalizedCommand !== normalizedDetail && normalizedRawCommand !== normalizedDetail)) - ) { + if (commandTool && command) { + const output = extractToolOutput(payload); + if (output) return output; + } + + const data = asRecord(payload?.data); + const repeatsCommand = + detail !== null && + commandDetailRepeatsCommand({ + detail, + command, + rawCommand: commandPreview.rawCommand, + toolName: data?.toolName, + data, + }); + + if (detail && normalizedHeading !== normalizedDetail && (!commandTool || !repeatsCommand)) { return detail; } if (commandTool) { - if (!command) { - return null; - } - - const output = extractToolOutput(payload); - const normalizedOutput = normalizePreviewForComparison(output); - if ( - output && - normalizedOutput !== normalizedHeading && - normalizedOutput !== normalizedCommand - ) { - return output; - } return null; } diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 1d2cedee544cb..8d179c417b727 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -3,11 +3,96 @@ import { describe, expect, it } from "vite-plus/test"; import { ThreadId } from "@t3tools/contracts"; import { + commandDetailRepeatsCommand, + extractCommandOutputText, resolveViewedImageAsset, toolGroupAction, workEntryViewedImagePath, } from "./presentation.js"; +describe("command work-log details", () => { + it("extracts Claude result blocks and projected output", () => { + expect( + extractCommandOutputText({ + result: { + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }, + }), + ).toBe("first\nsecond"); + expect(extractCommandOutputText({ rawOutput: { content: "projected summary" } })).toBe( + "projected summary", + ); + }); + + it("only removes a detail with the matching tool-name prefix", () => { + expect( + commandDetailRepeatsCommand({ + detail: "Bash: printf hello", + command: "printf hello", + rawCommand: null, + toolName: "Bash", + data: { toolName: "Bash", command: "printf hello" }, + }), + ).toBe(true); + expect( + commandDetailRepeatsCommand({ + detail: "warning: printf hello", + command: "printf hello", + rawCommand: null, + toolName: "Bash", + data: { toolName: "Bash", command: "printf hello" }, + }), + ).toBe(false); + }); + + it("treats an ingestion-truncated echo of a long command as a repeat", () => { + const command = `git add -A && git commit -m "${"x".repeat(200)}"`; + const truncated = `Bash: ${command}`.slice(0, 177) + "..."; + expect( + commandDetailRepeatsCommand({ + detail: truncated, + command, + rawCommand: null, + toolName: "Bash", + data: { toolName: "Bash", command }, + }), + ).toBe(true); + expect( + commandDetailRepeatsCommand({ + detail: "Bash: printf hello...", + command: "printf goodbye", + rawCommand: null, + toolName: "Bash", + data: { toolName: "Bash", command: "printf goodbye" }, + }), + ).toBe(false); + }); + + it("treats ACP command echoes as synthetic even without a tool kind", () => { + expect( + commandDetailRepeatsCommand({ + detail: "pnpm test", + command: "pnpm test", + rawCommand: null, + toolName: undefined, + data: { toolCallId: "tool-1", command: "pnpm test" }, + }), + ).toBe(true); + expect( + commandDetailRepeatsCommand({ + detail: "pnpm test", + command: "pnpm test", + rawCommand: null, + toolName: undefined, + data: { command: "pnpm test" }, + }), + ).toBe(false); + }); +}); + describe("workEntryViewedImagePath", () => { const entry = { label: "Read", tone: "tool" } as const; diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index f1334754c72e2..61c49d9ce0eff 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -51,6 +51,147 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function commandResultContent(value: unknown): string | null { + const direct = nonEmptyString(value); + if (direct) return direct; + + const directContent = Array.isArray(value) ? value : null; + const record = asRecord(value); + const content = record?.content; + const contentText = nonEmptyString(content); + if (contentText) return contentText; + const blocks = directContent ?? (Array.isArray(content) ? content : null); + if (!blocks) return null; + + const chunks = blocks.flatMap((entry) => { + const text = nonEmptyString(entry) ?? nonEmptyString(asRecord(entry)?.text); + return text ? [text] : []; + }); + return chunks.length > 0 ? chunks.join("\n") : null; +} + +/** Returns provider command output before it is formatted for a work-log row. */ +export function extractCommandOutputText(dataValue: unknown): string | null { + const data = asRecord(dataValue); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const rawOutput = asRecord(data?.rawOutput); + const outputStreams = [ + nonEmptyString(rawOutput?.stdout), + nonEmptyString(rawOutput?.stderr), + ].filter((value): value is string => value !== null); + const acpContent = Array.isArray(data?.content) + ? data.content + .flatMap((entryValue) => { + const entry = asRecord(entryValue); + const content = asRecord(entry?.content); + const text = entry?.type === "content" ? nonEmptyString(content?.text) : null; + return text ? [text] : []; + }) + .join("\n") + : null; + + const candidates = [ + item?.aggregatedOutput, + itemResult?.content, + data?.rawOutput, + rawOutput?.content, + outputStreams.length > 0 ? outputStreams.join("\n") : null, + rawOutput?.output, + acpContent, + data?.result, + ]; + for (const candidate of candidates) { + const text = commandResultContent(candidate); + if (text) return text; + } + return null; +} + +/** + * Ingestion caps tool details at 180 chars and appends "...", so a long command + * echo no longer equals the command it repeats. Treat a truncated prefix of the + * command as the same echo. + */ +function textRepeatsCommand(text: string, commands: ReadonlyArray): boolean { + const truncated = text.endsWith("...") + ? text.slice(0, -3) + : text.endsWith("\u2026") + ? text.slice(0, -1) + : null; + return commands.some((candidate) => { + const command = candidate?.trim(); + if (!command) return false; + if (command === text) return true; + return ( + truncated !== null && + truncated.length > 0 && + command.length > truncated.length && + command.startsWith(truncated) + ); + }); +} + +/** + * Decides whether a command row's `detail` is a synthetic echo of the command + * rather than real output. OpenCode stores completed output in `detail` with no + * other output channel, so plain equality is only treated as synthetic when the + * payload shape shows the detail came from the command: Codex item metadata, + * an ACP tool call (`data.toolCallId`, `kind: "execute"`), a Claude tool-name + * prefix, or no structured command at all. + */ +export function commandDetailRepeatsCommand(input: { + readonly detail: string; + readonly command: string | null; + readonly rawCommand: string | null; + readonly toolName: unknown; + readonly data: unknown; +}): boolean { + const toolName = nonEmptyString(input.toolName)?.trim(); + const detail = input.detail.trim(); + const commands = [input.command, input.rawCommand]; + if (toolName) { + const prefix = `${toolName}:`; + if (detail.toLowerCase().startsWith(prefix.toLowerCase())) { + const unprefixed = detail.slice(prefix.length).trim(); + if (textRepeatsCommand(unprefixed, commands)) return true; + } + } + + if (!textRepeatsCommand(detail, commands)) return false; + + const data = asRecord(input.data); + const item = asRecord(data?.item); + const itemInput = asRecord(item?.input); + const itemResult = asRecord(item?.result); + const hasStructuredCommand = [ + item?.command, + itemInput?.command, + itemResult?.command, + data?.command, + ].some((value) => + Array.isArray(value) + ? value.some((part) => nonEmptyString(part) !== null) + : nonEmptyString(value) !== null, + ); + return ( + !hasStructuredCommand || + item !== null || + data?.toolCallId !== undefined || + nonEmptyString(data?.kind)?.toLowerCase() === "execute" + ); +} + function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true; if (entry.command !== undefined && entry.command.trim().length > 0) return true; From a434677eca737771dd64703545c13eb11ba92ce2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:48:19 -0700 Subject: [PATCH 058/136] fix(grok): health check, model selection, and stop all work against the real CLI (#9154) Co-authored-by: mavenskylab Co-authored-by: Claude Fable 5.1 --- apps/server/scripts/acp-mock-agent.ts | 24 +- .../src/provider/Layers/GrokAdapter.test.ts | 315 +++++++++++++++++- .../server/src/provider/Layers/GrokAdapter.ts | 138 ++++++-- .../src/provider/Layers/GrokProvider.test.ts | 198 ++++++++++- .../src/provider/Layers/GrokProvider.ts | 229 +++++++++---- .../src/provider/acp/AcpRuntimeModel.ts | 15 +- .../src/provider/acp/AcpSessionRuntime.ts | 50 ++- .../src/provider/acp/GrokAcpSupport.test.ts | 30 ++ .../server/src/provider/acp/GrokAcpSupport.ts | 18 +- .../src/provider/acp/XAiAcpExtension.ts | 6 +- docs/internals/providers.md | 11 + packages/contracts/src/model.ts | 1 + packages/effect-acp/src/protocol.test.ts | 13 +- packages/effect-acp/src/protocol.ts | 40 ++- 14 files changed, 936 insertions(+), 152 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index e51a8883f4f94..294c143bc3bc0 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -293,13 +293,22 @@ function modeState(): AcpSchema.SessionModeState { }; } +// Mirrors the real Grok ACP: it advertises versioned model ids, never the CLI's own +// "grok-build" product name, and it rejects unknown ids in session/set_model. const grokAcpModels: ReadonlyArray = [ { - modelId: "grok-build", - name: "Grok Build", - ...(initialGrokReasoningEffort - ? { _meta: { reasoningEffort: initialGrokReasoningEffort } } - : {}), + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + totalContextTokens: 500_000, + supportsReasoningEffort: true, + reasoningEffort: initialGrokReasoningEffort ?? "high", + reasoningEfforts: [ + { id: "xhigh", value: "xhigh", label: "Extra High Effort", default: false }, + { id: "high", value: "high", label: "High Effort", default: true }, + { id: "low", value: "low", label: "Low Effort", default: false }, + ], + }, }, { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, ]; @@ -307,7 +316,7 @@ const grokAcpModels: ReadonlyArray = [ function modelState(): AcpSchema.SessionModelState { const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) ? currentModelId - : "grok-build"; + : "grok-4.6"; return { currentModelId: modelId, availableModels: grokAcpModels, @@ -324,6 +333,9 @@ const program = Effect.gen(function* () { return { protocolVersion: 1, agentCapabilities: { loadSession: true }, + // Grok advertises model state before any session exists; the provider + // health check reads it from here without authenticating. + _meta: { modelState: modelState() }, }; }), ); diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index eeee17d9ac6ac..e2649006b8fcc 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -720,13 +720,13 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { 0, ); - yield* Fiber.interrupt(steerSendTurnFiber); yield* adapter.interruptTurn(threadId); const completed = yield* Deferred.await(turnCompleted).pipe( Effect.timeout("2 seconds"), TestClock.withLive, ); yield* Fiber.join(firstSendTurnFiber); + yield* Fiber.interrupt(steerSendTurnFiber); assert.equal(completed.payload.state, "cancelled"); yield* Fiber.interrupt(runtimeEventsFiber); @@ -1122,6 +1122,316 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }).pipe(TestClock.withLive), ); + it.effect("cancels an in-flight prompt when a mid-turn sendTurn steers", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-steer-cancels-in-flight"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-steer-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hang until steered", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); + yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"'); + + const steered = yield* adapter + .sendTurn({ threadId, input: "take this instead", attachments: [] }) + .pipe(Effect.timeout("3 seconds")); + yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds")); + + const requestLog = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const methods = requestLog.flatMap((entry) => + typeof entry.method === "string" ? [entry.method] : [], + ); + const turnStartedEvents = runtimeEvents.filter( + (event) => event.type === "turn.started" && String(event.threadId) === String(threadId), + ); + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.equal(String(steered.turnId), String(firstTurnId)); + assert.isTrue(methods.includes("session/cancel")); + assert.isAtLeast(methods.filter((method) => method === "session/prompt").length, 2); + assert.lengthOf(turnStartedEvents, 1); + assert.lengthOf(turnCompletedEvents, 1); + assert.equal(turnCompletedEvents[0]?.payload.state, "completed"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect( + "steers a prompt that has not started ACP yet instead of letting it start after cancel", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-steer-during-prep"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-steer-prep-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "still preparing", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe( + Effect.timeout("2 seconds"), + ); + + const steered = yield* adapter + .sendTurn({ threadId, input: "steer before first prompt starts", attachments: [] }) + .pipe(Effect.timeout("3 seconds")); + yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds")); + + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.equal(String(steered.turnId), String(firstTurnId)); + assert.lengthOf(turnCompletedEvents, 1); + assert.equal(turnCompletedEvents[0]?.payload.state, "completed"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("keeps the original prompt running when a steer fails during preparation", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-failed-steer-keeps-original-prompt"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-failed-steer-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hang until a failed steer", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); + + const steerError = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: " ", + attachments: [], + }), + ); + yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"'); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const sessionsAfterFailedSteer = yield* adapter.listSessions(); + const sessionAfterFailedSteer = sessionsAfterFailedSteer.find( + (session) => session.threadId === threadId, + ); + const completedBeforeInterrupt = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + + yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds")); + + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.equal(steerError._tag, "ProviderAdapterValidationError"); + assert.equal(sessionAfterFailedSteer?.status, "running"); + assert.equal(String(sessionAfterFailedSteer?.activeTurnId), String(firstTurnId)); + assert.lengthOf(completedBeforeInterrupt, 0); + assert.lengthOf(turnCompletedEvents, 1); + assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurnId)); + assert.equal(turnCompletedEvents[0]?.payload.state, "cancelled"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("does not double-complete when interrupt wins before a prompt starts ACP", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-interrupt-before-prompt-start"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "interrupt before prompt starts", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); + yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds")); + + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.lengthOf(turnCompletedEvents, 1); + assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurnId)); + assert.equal(turnCompletedEvents[0]?.payload.state, "cancelled"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + it.effect("drops late ACP notifications after a turn is cancelled", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-drop-late-cancelled-notifications"); @@ -1429,7 +1739,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { assert.equal(error._tag, "ProviderAdapterRequestError"); assert.include(error.message, "Grok usage limit reached. Try again later."); assert.equal(readySession?.status, "ready"); - assert.equal(readySession?.model, "grok-build"); + // "grok-build" resolves to the session's current model instead of going over the wire. + assert.equal(readySession?.model, "grok-4.6"); assert.isUndefined(readySession?.activeTurnId); assert.lengthOf(terminalEvents, 1); const [terminalEvent] = terminalEvents; diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 4bec298c628d7..da9bdf6030f43 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -151,8 +151,15 @@ interface GrokSessionContext { interruptedTurnIds: Set; /** Number of sendTurn prompts currently in flight or being prepared. * >0 means a turn is actively running, so a new sendTurn is a steer that - * continues it, and only the last remaining prompt settles the turn. */ + * cancels the in-flight prompt and continues the same turn. Only the last + * remaining prompt settles the turn. */ promptsInFlight: number; + /** Monotonic id assigned to each sendTurn. Steers discard older epochs. */ + promptEpoch: number; + /** Prompt epochs below this value must not start an ACP session/prompt. */ + discardBeforeEpoch: number; + /** Serializes cancel-then-prompt so a steer cannot miss or hit the wrong RPC. */ + readonly promptLifecycle: Semaphore.Semaphore; readonly livenessSignals: Queue.Queue; livenessTurnId: TurnId | undefined; lastTurnActivityAtNanos: bigint | undefined; @@ -1279,6 +1286,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte activeTurnId: undefined, interruptedTurnIds: new Set(), promptsInFlight: 0, + promptEpoch: 0, + discardBeforeEpoch: 0, + promptLifecycle: yield* Semaphore.make(1), livenessSignals: yield* Queue.sliding(1), livenessTurnId: undefined, lastTurnActivityAtNanos: undefined, @@ -1466,15 +1476,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte input.threadId, Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); - // A sendTurn while a prompt is in flight is a steer: the agent - // folds the new prompt into the ongoing work, so the active turn - // id is reused instead of opening a new turn. + // A sendTurn while a prompt is in flight is a steer: reuse the + // active turn and cancel the in-flight ACP prompt so Grok takes + // the new instruction immediately, matching Claude/Codex, instead + // of waiting behind serialized session/prompt. const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); // Count this prompt immediately so a superseded in-flight prompt // resolving from here on does not settle the turn; decremented on // preparation failure here, and after the prompt below otherwise. ctx.promptsInFlight += 1; + ctx.promptEpoch += 1; + const promptEpoch = ctx.promptEpoch; // Bind the turn id before cooperative yields so interruptTurn can // settle this prompt even if stop arrives during preparation. ctx.activeTurnId = turnId; @@ -1610,6 +1623,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte turnId, payload: displayModel ? { model: displayModel } : {}, }); + } else { + // Discard the previous epoch only after this replacement is + // ready. A failed steer must not skip the live prompt, which + // settles without a terminal event when emitTurnCompletion is + // false. + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + ctx.discardBeforeEpoch = promptEpoch; } return { @@ -1618,6 +1639,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte displayModel, promptParts, turnId, + promptEpoch, + promptLifecycle: ctx.promptLifecycle, + steeringTurnId, }; }).pipe( Effect.tapCause(() => @@ -1644,31 +1668,91 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const promptFailureMessageRef = yield* Ref.make(undefined); return yield* Effect.gen(function* () { - const result = yield* prepared.acp - .prompt({ - prompt: prepared.promptParts, - }) - .pipe( - Effect.tap((promptResult) => - Effect.all( - [ - Ref.set(promptRpcSucceeded, true), - Ref.set(promptResultRef, promptResult), - markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId), - ], - { discard: true }, - ), - ), - Effect.tapError((error) => - Ref.set( - promptFailureMessageRef, - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, - ).pipe(Effect.andThen(prepared.acp.drainEvents)), - ), - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + const promptStart = yield* prepared.promptLifecycle.withPermit( + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + const interrupted = liveCtx?.interruptedTurnIds.has(prepared.turnId) === true; + if ( + !liveCtx || + liveCtx.acpSessionId !== prepared.acpSessionId || + prepared.promptEpoch < liveCtx.discardBeforeEpoch || + interrupted + ) { + return { _tag: "Skipped" as const, interrupted }; + } + if (prepared.steeringTurnId !== undefined) { + yield* Effect.ignore( + liveCtx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/cancel", error), + ), + ), + ); + } + if (liveCtx.interruptedTurnIds.has(prepared.turnId)) { + return { _tag: "Skipped" as const, interrupted: true }; + } + const dispatched = yield* Deferred.make(); + const fiber = yield* liveCtx.acp + .prompt({ prompt: prepared.promptParts }, { dispatched }) + .pipe(Effect.forkChild({ startImmediately: true })); + // Hold the lifecycle permit until the runtime has registered this + // prompt's RPC fiber, so a later steer's session/cancel targets + // this prompt. Fall through if the prompt fails before that point. + yield* Effect.raceFirst( + Deferred.await(dispatched), + Fiber.await(fiber).pipe(Effect.asVoid), + ); + return { _tag: "Started" as const, fiber }; + }), + ); + if (promptStart._tag === "Skipped") { + // Settle after releasing promptLifecycle. Holding both locks + // deadlocks the next sendTurn, which takes the thread lock first. + yield* withThreadLock( + input.threadId, + settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + promptStart.interrupted + ? { + completedStopReason: "cancelled", + settleAllPrompts: true, + } + : { emitTurnCompletion: false }, ), ); + yield* Ref.set(promptSettled, true); + const liveCtx = sessions.get(input.threadId); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: liveCtx?.session.resumeCursor, + }; + } + + const result = yield* Fiber.join(promptStart.fiber).pipe( + Effect.tap((promptResult) => + Effect.all( + [ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId), + ], + { discard: true }, + ), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); return yield* withThreadLock( input.threadId, diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 307f55eeb5a89..e7c62f4cbf77d 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - resolves the mock ACP agent script path relative to this test file. +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -8,11 +12,74 @@ import { GrokSettings } from "@t3tools/contracts"; import { buildGrokModelCapabilities, + buildGrokModelsFromSessionModelState, buildInitialGrokProviderSnapshot, checkGrokProviderStatus, + parseGrokModelsCliOutput, } from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); + +const LOGGED_IN_MODELS_OUTPUT = [ + "You are logged in with grok.com.", + "", + "Default model: grok-4.6", + "", + "Available models:", + " * grok-4.6 (default)", + " - grok-4.5", + "", +].join("\n"); + +const LOGGED_OUT_MODELS_OUTPUT = LOGGED_IN_MODELS_OUTPUT.replace( + "You are logged in with grok.com.", + "You are not authenticated.", +); + +describe("parseGrokModelsCliOutput", () => { + it("reads login state and model slugs, marking the default", () => { + const parsed = parseGrokModelsCliOutput(LOGGED_IN_MODELS_OUTPUT); + expect(parsed.authenticated).toBe(true); + expect(parsed.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + }); + + it("detects a logged-out CLI even though it exits 0", () => { + expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false); + }); + + it("returns unknown auth for unrecognized output", () => { + expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull(); + }); +}); + +describe("buildGrokModelsFromSessionModelState", () => { + it("marks the agent's current model as default and keeps reasoning options", () => { + const models = buildGrokModelsFromSessionModelState({ + currentModelId: "grok-4.6", + availableModels: [ + { + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "high", + reasoningEfforts: [{ value: "high", label: "High", default: true }], + }, + }, + { modelId: "grok-4.5", name: "Grok 4.5" }, + ], + }); + expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1); + }); +}); describe("buildGrokModelCapabilities", () => { it("preserves ACP-provided reasoning labels and the active default", () => { @@ -264,30 +331,139 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); - it.effect("reports an error when ACP model discovery is unavailable", () => + // Single-quotes a path for /bin/sh. Temp dirs and execPath never contain quotes. + const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + + // A shell stand-in for the Grok CLI: `--version` and `models` print canned text, + // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. + const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); + const modelsPath = path.join(dir, "models.txt"); + yield* fs.writeFileString(modelsPath, input.modelsOutput); + const grokPath = path.join(dir, "grok"); + const mockAgentPath = path.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); + yield* fs.writeFileString( + grokPath, + [ + "#!/bin/sh", + 'case "$1" in', + ' --version) printf "grok 1.0.13\\n"; exit 0;;', + ` models) cat ${shellQuote(modelsPath)}; exit 0;;`, + input.acp + ? ` agent) exec ${shellQuote(process.execPath)} ${shellQuote(mockAgentPath)};;` + : " agent) exit 3;;", + "esac", + "exit 1", + "", + ].join("\n"), + ); + yield* fs.chmod(grokPath, 0o755); + return grokPath; + }); + + it.effect("reports ready with ACP-discovered models when logged in", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-success-" }); - const grokPath = path.join(dir, "grok"); - yield* fs.writeFileString( - grokPath, - ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_IN_MODELS_OUTPUT, + acp: true, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, ); - yield* fs.chmod(grokPath, 0o755); + }), + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("1.0.13"); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "cached_token", + label: "Grok account", + }); + // The mock agent advertises grok-4.6 with reasoning options in initialize._meta. + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); + expect(snapshot.models[0]?.isDefault).toBe(true); + expect( + snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [], + ).toEqual(["reasoningEffort"]); + }), + ); + + it.effect("reports unauthenticated from `grok models` without starting a session", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_OUT_MODELS_OUTPUT, + acp: true, + }); return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, ); }), ); expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain("grok login"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); + }), + ); + + it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_IN_MODELS_OUTPUT, + acp: false, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, + ); + }), + ); + + expect(snapshot.status).toBe("warning"); expect(snapshot.installed).toBe(true); - expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); - expect(snapshot.message).toContain("ACP startup failed"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + expect(snapshot.message).toContain("ACP initialize failed"); + }), + ); + + it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_OUT_MODELS_OUTPUT, + acp: false, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "xai-test-key" }, + ); + }), + ); + + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "api_key", + label: "xAI API key", + }); + expect(snapshot.status).toBe("warning"); }), ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 923e1781711d5..753dc2f4a4317 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -2,6 +2,7 @@ import { type GrokSettings, type ModelCapabilities, type ServerProvider, + type ServerProviderAuth, type ServerProviderModel, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -18,6 +19,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { + AUTH_PROBE_TIMEOUT_MS, buildServerProvider, isCommandMissingCause, parseGenericCliVersion, @@ -30,10 +32,12 @@ import { type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; import { + GROK_DEFAULT_MODEL_SLUG, isValidGrokReasoningEffortToken, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; +import { sessionModelStateFromInitialize } from "../acp/AcpRuntimeModel.ts"; import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; const GROK_PRESENTATION = { @@ -46,11 +50,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ }); const VERSION_PROBE_TIMEOUT_MS = 4_000; -const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +// `initialize` is a single local round trip, so this is generous even on slow machines. +const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000; +const GROK_API_KEY_ENV = "XAI_API_KEY"; const GROK_BUILT_IN_MODELS: ReadonlyArray = [ { - slug: "grok-build", + slug: GROK_DEFAULT_MODEL_SLUG, name: "Grok Build", isCustom: false, capabilities: EMPTY_CAPABILITIES, @@ -201,56 +207,94 @@ export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): Mo : EMPTY_CAPABILITIES; } -function buildGrokDiscoveredModelsFromSessionModelState( +/** Models advertised by the ACP agent, with the session's current model marked as default. */ +export function buildGrokModelsFromSessionModelState( modelState: EffectAcpSchema.SessionModelState | null | undefined, ): ReadonlyArray { if (!modelState || modelState.availableModels.length === 0) { return []; } + const currentModelId = modelState.currentModelId.trim(); const seen = new Set(); - return modelState.availableModels - .map((model): ServerProviderModel | undefined => { - const slug = resolveGrokAcpBaseModelId(model.modelId); - if (!slug || seen.has(slug)) { - return undefined; - } - seen.add(slug); - return { + return modelState.availableModels.flatMap((model): ServerProviderModel[] => { + const slug = resolveGrokAcpBaseModelId(model.modelId); + if (!slug || seen.has(slug)) { + return []; + } + seen.add(slug); + return [ + { slug, name: model.name.trim() || slug, isCustom: false, + ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}), capabilities: buildGrokModelCapabilities(model), - }; - }) - .filter((model): model is ServerProviderModel => model !== undefined); + }, + ]; + }); } -const discoverGrokModelsViaAcp = ( - grokSettings: GrokSettings, - environment: NodeJS.ProcessEnv = process.env, -) => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const acp = yield* makeGrokAcpRuntime({ - grokSettings, - environment, - childProcessSpawner, - cwd: process.cwd(), - clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, +export interface GrokModelsCliOutput { + /** True or false when the CLI printed a login line, null when it printed neither. */ + readonly authenticated: boolean | null; + readonly models: ReadonlyArray; +} + +/** + * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the + * text is the only signal. Current output looks like: + * + * You are logged in with grok.com. + * Default model: grok-4.6 + * Available models: + * * grok-4.6 (default) + * - grok-4.5 + */ +export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput { + const authenticated = /you are logged in/i.test(output) + ? true + : /not authenticated|not logged in/i.test(output) + ? false + : null; + + const seen = new Set(); + const models: ServerProviderModel[] = []; + for (const line of output.split(/\r?\n/)) { + const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/); + if (!bullet?.[1]) { + continue; + } + const slug = resolveGrokAcpBaseModelId(bullet[1]); + if (seen.has(slug)) { + continue; + } + seen.add(slug); + models.push({ + slug, + name: displayNameFromGrokModelSlug(slug), + isCustom: false, + ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}), + capabilities: EMPTY_CAPABILITIES, }); - const started = yield* acp.start(); - return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); - }).pipe(Effect.scoped); + } + return { authenticated, models }; +} + +function displayNameFromGrokModelSlug(slug: string): string { + return slug + .split(/[-_]/g) + .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part)) + .join(" "); +} -const runGrokVersionCommand = ( +const runGrokCliCommand = ( grokSettings: GrokSettings, - environment: NodeJS.ProcessEnv = process.env, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, ) => Effect.gen(function* () { const command = grokSettings.binaryPath || "grok"; - const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { - env: environment, - }); + const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); return yield* spawnAndCollect( command, ChildProcess.make(spawnCommand.command, spawnCommand.args, { @@ -260,6 +304,27 @@ const runGrokVersionCommand = ( ); }); +/** + * Reads model metadata from `initialize._meta.modelState`. This never calls `authenticate` + * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers. + */ +const discoverGrokModelsViaAcpInitialize = ( + grokSettings: GrokSettings, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeGrokAcpRuntime({ + grokSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const initialized = yield* acp.initialize(); + return buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)); + }).pipe(Effect.scoped); + export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, @@ -288,7 +353,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const versionResult = yield* runGrokVersionCommand(grokSettings, environment).pipe( + const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe( Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result, ); @@ -354,55 +419,76 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } + // `grok models` reports login state and model slugs without starting the agent. + const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe( + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.result, + ); + // Only a clean exit is parsed. Failed invocations print help or error text that + // must not be read as model slugs or as a login verdict. + const modelsOutput = + Result.isSuccess(modelsResult) && + Option.isSome(modelsResult.success) && + modelsResult.success.value.code === 0 + ? modelsResult.success.value + : undefined; + const cliModels: GrokModelsCliOutput = modelsOutput + ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`) + : { authenticated: null, models: [] }; + if (!modelsOutput) { + yield* Effect.logWarning("Grok CLI model listing failed or timed out.", { + errorTag: Result.isFailure(modelsResult) + ? modelsResult.failure._tag + : Option.isNone(modelsResult.success) + ? "Timeout" + : `ExitCode${modelsResult.success.value.code}`, + }); + } + + const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim() + ? { status: "authenticated", type: "api_key", label: "xAI API key" } + : cliModels.authenticated === true + ? { status: "authenticated", type: "cached_token", label: "Grok account" } + : cliModels.authenticated === false + ? { status: "unauthenticated" } + : { status: "unknown" }; + const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( - Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe( + Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), Effect.exit, ); - if (Exit.isFailure(discoveryExit)) { - yield* Effect.logWarning("Grok ACP model discovery failed", { - errorTag: causeErrorTag(discoveryExit.cause), - }); - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - skills, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: "Grok CLI is installed but ACP startup failed. Check server logs for details.", - }, + const acpModels = Exit.isSuccess(acpExit) ? Option.getOrElse(acpExit.value, () => []) : []; + const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); + if (acpFailed) { + yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", { + errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout", }); } - if (Option.isNone(discoveryExit.value)) { - yield* Effect.logWarning( - `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, - ); + + const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models; + const models = + discoveredModels.length > 0 + ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) + : fallbackModels; + + if (auth.status === "unauthenticated") { return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, - models: fallbackModels, + models, skills, probe: { installed: true, version, status: "error", - auth: { status: "unknown" }, - message: `Grok CLI is installed but ACP startup timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + auth, + message: "Grok CLI is installed but not logged in. Run `grok login`.", }, }); } - const discoveredModels = discoveryExit.value.value; - const models = - discoveredModels.length > 0 - ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) - : fallbackModels; return buildServerProvider({ presentation: GROK_PRESENTATION, @@ -413,8 +499,15 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func probe: { installed: true, version, - status: "ready", - auth: { status: "unknown" }, + // A failed metadata probe degrades the model picker, it does not make chats fail. + status: acpFailed ? "warning" : "ready", + auth, + ...(acpFailed + ? { + message: + "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", + } + : {}), }, }); }); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index a81a2faf5e046..c680143e4053e 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -713,13 +713,24 @@ export const waitForSessionLoadReplayIdle = (input: { } }); +/** + * Model state some agents (Grok) advertise in `initialize._meta.modelState`, before any + * session exists. Undefined when the agent does not advertise it or the shape is unknown. + */ +export function sessionModelStateFromInitialize( + initializeResult: EffectAcpSchema.InitializeResponse, +): EffectAcpSchema.SessionModelState | undefined { + const meta = initializeResult._meta; + const modelState = isRecord(meta) ? meta.modelState : undefined; + return isSessionModelState(modelState) ? modelState : undefined; +} + export function syntheticLoadSessionResponseFromInitialize( initializeResult: EffectAcpSchema.InitializeResponse, ): EffectAcpSchema.LoadSessionResponse { const meta = initializeResult._meta; - const modelState = isRecord(meta) ? meta.modelState : undefined; const modeState = isRecord(meta) ? meta.modeState : undefined; - const models = isSessionModelState(modelState) ? modelState : undefined; + const models = sessionModelStateFromInitialize(initializeResult); const modes = isSessionModeState(modeState) ? modeState : undefined; return { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 2a4cb6a2337bc..14ef1540fb8df 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -182,6 +182,16 @@ export class AcpSessionRuntime extends Context.Service< * @see https://agentclientprotocol.com/protocol/extensibility */ readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"]; + /** + * Sends only `initialize` and returns the agent's response. Health probes use this to read + * advertised capabilities without authenticating or opening a session, so a probe can never + * start an interactive login or boot MCP servers. + * @see https://agentclientprotocol.com/protocol/schema#initialize + */ + readonly initialize: () => Effect.Effect< + EffectAcpSchema.InitializeResponse, + EffectAcpErrors.AcpError + >; /** * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. * Concurrent calls share the same in-flight startup and a failed startup may be retried. @@ -196,11 +206,14 @@ export class AcpSessionRuntime extends Context.Service< /** Latest configuration options observed from session setup and configuration writes. */ readonly getConfigOptions: Effect.Effect>; /** - * Sends a prompt turn to the active session. + * Sends a prompt turn to the active session. `options.dispatched` settles once the + * `session/prompt` RPC is registered as the active prompt, so a caller that forks this + * effect knows when a later `cancel` will target this prompt. * @see https://agentclientprotocol.com/protocol/schema#session/prompt */ readonly prompt: ( payload: Omit, + options?: { readonly dispatched?: Deferred.Deferred }, ) => Effect.Effect; /** * Sends a real ACP `session/cancel` notification for the active session. @@ -537,18 +550,19 @@ export const make = ( ), ); + const initializePayload = { + protocolVersion: 1, + clientCapabilities: initializeClientCapabilities, + clientInfo: options.clientInfo, + } satisfies EffectAcpSchema.InitializeRequest; + const sendInitialize = runLoggedRequest( + "initialize", + initializePayload, + acp.agent.initialize(initializePayload), + ); + const startOnce = Effect.gen(function* () { - const initializePayload = { - protocolVersion: 1, - clientCapabilities: initializeClientCapabilities, - clientInfo: options.clientInfo, - } satisfies EffectAcpSchema.InitializeRequest; - - const initializeResult = yield* runLoggedRequest( - "initialize", - initializePayload, - acp.agent.initialize(initializePayload), - ); + const initializeResult = yield* sendInitialize; const authenticatePayload = { methodId: options.authMethodId, @@ -713,6 +727,7 @@ export const make = ( handleUnknownExtNotification: acp.handleUnknownExtNotification, handleExtRequest: acp.handleExtRequest, handleExtNotification: acp.handleExtNotification, + initialize: () => sendInitialize, start: () => start, getEvents: () => Stream.fromQueue(eventQueue), drainEvents: Effect.gen(function* () { @@ -725,7 +740,7 @@ export const make = ( }), getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), - prompt: (payload) => + prompt: (payload, promptOptions?) => promptSerializationSemaphore.withPermit( Effect.gen(function* () { const started = yield* getStartedState; @@ -746,6 +761,9 @@ export const make = ( acp.agent.prompt(requestPayload), ).pipe(Effect.forkIn(runtimeScope)); yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); + if (promptOptions?.dispatched) { + yield* Deferred.succeed(promptOptions.dispatched, undefined); + } return yield* Fiber.join(promptRpcFiber).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) @@ -774,9 +792,9 @@ export const make = ( if (Option.isSome(activePromptFiber)) { yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); } - yield* acp.agent - .cancel({ sessionId: started.sessionId }) - .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); + // Await the notification write so a replacement session/prompt + // cannot race ahead of session/cancel on the wire. + yield* acp.agent.cancel({ sessionId: started.sessionId }).pipe(Effect.ignore); }), ), ), diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index a85f7174a1c08..3f64128c5849e 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -178,6 +178,36 @@ describe("applyGrokAcpModelSelection", () => { }), ); + it.effect("keeps the session's current model when the product slug is requested", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + requestedModelId: "grok-build", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("grok-4.6"); + }), + ); + + it.effect("applies reasoning to the current model when the product slug is requested", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-build", + requestedReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]); + expect(result).toBe("grok-4.6"); + }), + ); + it.effect("skips set_model when no model is requested", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 001f0adfe7b87..e350038352691 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -98,10 +98,16 @@ export const makeGrokAcpRuntime = ( return yield* makeXAiPromptCompletionRuntime(runtime); }); +/** + * T3's built-in Grok slug. It is the CLI's product name, not a model id the ACP accepts, + * so selecting it means "use whatever model the Grok session currently runs on". + */ +export const GROK_DEFAULT_MODEL_SLUG = "grok-build"; + export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { const trimmed = model?.trim(); - const base = trimmed && trimmed.length > 0 ? trimmed : "grok-build"; - return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build"; + const base = trimmed && trimmed.length > 0 ? trimmed : GROK_DEFAULT_MODEL_SLUG; + return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG; } const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; @@ -155,15 +161,17 @@ export function applyGrokAcpModelSelection(input: { readonly requestedReasoningEffort?: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { - const modelChanged = - input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + // The product slug is never sent over the wire; it keeps the session's current model. + const requestedModelId = + input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId; + const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId; const reasoningProvided = input.requestedReasoningEffort !== undefined; const reasoningEffort = reasoningProvided ? normalizeGrokReasoningEffort(input.requestedReasoningEffort) : undefined; const reasoningEffortChanged = reasoningProvided && reasoningEffort !== input.currentReasoningEffort; - const targetModelId = input.requestedModelId ?? input.currentModelId; + const targetModelId = requestedModelId ?? input.currentModelId; if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { return Effect.succeed(input.currentModelId); } diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 543edb39bb6db..620545569aaac 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -447,11 +447,11 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion runtime .start() .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))), - prompt: (payload) => + prompt: (payload, promptOptions?) => Effect.gen(function* () { const sessionId = yield* Ref.get(activeSessionIdRef); if (sessionId === undefined) { - return yield* runtime.prompt(payload); + return yield* runtime.prompt(payload, promptOptions); } const promptId = yield* allocatePromptFallbackId; @@ -470,7 +470,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion } satisfies Omit; return yield* Effect.raceFirst( - runtime.prompt(requestPayload), + runtime.prompt(requestPayload, promptOptions), Deferred.await(fallback.deferred), ).pipe( Effect.tap((response) => diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 75163f0cc0f4d..5229ad7781a9b 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -39,6 +39,17 @@ directory to route session and turn operations for a thread, so callers name a t Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No orchestration, contract, or client change is required for the common case. +### Grok health check + +`checkGrokProviderStatus` never opens an ACP session. It runs `grok --version`, then `grok models` +for login state and model slugs, then a single ACP `initialize` and reads models from +`_meta.modelState`. `authenticate` and `session/new` are skipped on purpose: `authenticate` can open +a browser login and `session/new` boots every configured MCP server, both of which made background +probes hang or surprise the user. A failed `initialize` degrades to `warning` with the CLI's model +list instead of persisting `error` over a working install. The built-in `grok-build` slug is the +CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the +session's current model" and never sends it in `session/set_model`. + ## OpenCode server ownership and catalog Each OpenCode provider instance owns one lazy local server for catalog discovery and diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 92080d0cb4ab0..6610a265d617d 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -151,6 +151,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial { yield* transport.notify("session/cancel", { sessionId: "session-1" }); + // A notification must not carry `id` or `headers`. Grok CLI drops frames that do. assert.deepEqual(events, [ { direction: "outgoing", stage: "decoded", payload: { - _tag: "Request", - id: "", + _tag: "Notification", tag: "session/cancel", payload: { sessionId: "session-1", }, - headers: [], }, }, { direction: "outgoing", stage: "raw", payload: - '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"},"id":"","headers":[]}\n', + '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}\n', }, ]); }), @@ -293,11 +292,13 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { serverRequestMethods: new Set(), }); + // Notifications encode through Schema, so the cause is the schema failure rather + // than the raw TypeError JSON.stringify throws. The ACP error shape is what callers see. const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); assert.equal(bigintError.operation, "encode-message"); assert.equal(bigintError.method, "x/test"); - assert.instanceOf(bigintError.cause, TypeError); + assert.isDefined(bigintError.cause); assert.equal( bigintError.message, "ACP protocol operation 'encode-message' failed for method 'x/test'.", @@ -309,7 +310,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.instanceOf(circularError, AcpError.AcpProtocolParseError); assert.equal(circularError.operation, "encode-message"); assert.equal(circularError.method, "x/test"); - assert.instanceOf(circularError.cause, TypeError); + assert.isDefined(circularError.cause); const requestError = yield* transport.request("x/request", 1n).pipe( Effect.match({ diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 44a48bd1ef760..9f993dedb6e71 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -77,6 +78,16 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( ); const parserFactory = RpcSerialization.ndJsonRpc(); const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; +// Outbound JSON-RPC notification: no `id`, so peers never treat it as a request. +const encodeJsonRpcNotification = Schema.encodeUnknownExit( + Schema.fromJsonString( + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + method: Schema.String, + params: Schema.Unknown, + }), + ), +); export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( options: AcpPatchedProtocolOptions, @@ -109,6 +120,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const offerOutgoing = Effect.fn("offerOutgoing")(function* ( message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, ) { + // RpcClient emits `@effect/rpc/Interrupt` when a pending request's fiber is interrupted. + // ACP has no such method; agents log it as an error and cannot act on it, so drop it. + if (message._tag === "Interrupt") { + return; + } yield* logProtocol({ direction: "outgoing", stage: "decoded", @@ -521,17 +537,29 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi supportsSpanPropagation: true, }); + // JSON-RPC notifications carry no `id`. The generic Request encoder emits `id: ""` plus + // `headers`, which real agents (Grok CLI) parse as a malformed request and silently drop. + // That made `session/cancel` a no-op against Grok while the lenient mock agent accepted it. const sendNotification = Effect.fn("sendNotification")(function* ( method: string, payload: unknown, ) { - yield* offerOutgoing({ - _tag: "Request", - id: "", - tag: method, - payload, - headers: [], + yield* logProtocol({ + direction: "outgoing", + stage: "decoded", + payload: { _tag: "Notification", tag: method, payload }, }); + const exit = encodeJsonRpcNotification({ jsonrpc: "2.0", method, params: payload }); + if (Exit.isFailure(exit)) { + return yield* AcpError.AcpProtocolParseError.fromEncodingError( + method, + undefined, + Cause.squash(exit.cause), + ); + } + const encoded = `${exit.value}\n`; + yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded }); + yield* Queue.offer(outgoing, encoded); }); const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { From 80c708a1fa8986bce0b2d1ba4d35e79d9e4094ba Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:51:00 +0200 Subject: [PATCH 059/136] perf(web): halve the cold-start bundle by splitting Clerk and cold routes (#9058) Co-authored-by: Claude Fable 5 Co-authored-by: Theo Browne --- apps/web/src/components/AppSidebarLayout.tsx | 15 +++- .../clerk/BrowserManagedAuthShell.tsx | 25 ++++++ .../clerk/ElectronManagedAuthShell.tsx | 26 ++++++ .../components/settings/ThemeEditorHost.tsx | 38 +++++---- apps/web/src/lib/chunkReloadGuard.test.ts | 56 +++++++++++++ apps/web/src/lib/chunkReloadGuard.ts | 39 +++++++++ apps/web/src/main.tsx | 81 +++++++++++++------ apps/web/src/router.ts | 4 + apps/web/vite.config.ts | 5 +- 9 files changed, 245 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/clerk/BrowserManagedAuthShell.tsx create mode 100644 apps/web/src/components/clerk/ElectronManagedAuthShell.tsx create mode 100644 apps/web/src/lib/chunkReloadGuard.test.ts create mode 100644 apps/web/src/lib/chunkReloadGuard.ts diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a3ba766796892..3d9e0e82e519f 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { + lazy, + Suspense, useEffect, useState, useSyncExternalStore, @@ -17,7 +19,6 @@ import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; -import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { resolveSidebarStageFocusRingOffsetClass, @@ -43,6 +44,14 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; +// The settings nav (and the Clerk profile surfaces behind it) only renders on +// settings routes; lazy-loading it keeps that subtree out of the startup chunk. +const SettingsSidebarNav = lazy(() => + import("./settings/SettingsSidebarNav").then((module) => ({ + default: module.SettingsSidebarNav, + })), +); + function subscribeToViewportWidth(onChange: () => void): () => void { window.addEventListener("resize", onChange); return () => window.removeEventListener("resize", onChange); @@ -229,7 +238,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {isOnSettings ? ( <> - + + + ) : legacySidebarEnabled ? ( diff --git a/apps/web/src/components/clerk/BrowserManagedAuthShell.tsx b/apps/web/src/components/clerk/BrowserManagedAuthShell.tsx new file mode 100644 index 0000000000000..285e9f7c760b2 --- /dev/null +++ b/apps/web/src/components/clerk/BrowserManagedAuthShell.tsx @@ -0,0 +1,25 @@ +import { ClerkProvider } from "@clerk/react"; +import type { ReactNode } from "react"; + +import { ManagedRelayAuthProvider } from "../../cloud/managedAuth"; +import { clerkAppearance } from "./clerkAppearance"; + +/** + * Browser half of the managed-auth boundary, loaded lazily from the entry so + * cloudless local mode never downloads a Clerk runtime. The browser provider + * stays small on its own: it hotloads clerk-js at runtime instead of bundling + * it. + */ +export default function BrowserManagedAuthShell({ + publishableKey, + children, +}: { + readonly publishableKey: string; + readonly children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/clerk/ElectronManagedAuthShell.tsx b/apps/web/src/components/clerk/ElectronManagedAuthShell.tsx new file mode 100644 index 0000000000000..3030c2a395589 --- /dev/null +++ b/apps/web/src/components/clerk/ElectronManagedAuthShell.tsx @@ -0,0 +1,26 @@ +import { passkeys } from "@clerk/electron/passkeys"; +import { ClerkProvider } from "@clerk/electron/react"; +import type { ReactNode } from "react"; + +import { ManagedRelayAuthProvider } from "../../cloud/managedAuth"; +import { clerkAppearance } from "./clerkAppearance"; + +/** + * Electron half of the managed-auth boundary. The Electron provider statically + * bundles the full clerk-js runtime, so this module must only ever load + * lazily, and only inside the desktop shell — importing it eagerly would put + * clerk-js back into every client's startup graph. + */ +export default function ElectronManagedAuthShell({ + publishableKey, + children, +}: { + readonly publishableKey: string; + readonly children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/settings/ThemeEditorHost.tsx b/apps/web/src/components/settings/ThemeEditorHost.tsx index faf2d770e90d7..7eca6a2de5231 100644 --- a/apps/web/src/components/settings/ThemeEditorHost.tsx +++ b/apps/web/src/components/settings/ThemeEditorHost.tsx @@ -1,11 +1,17 @@ -import { useCallback } from "react"; +import { lazy, Suspense, useCallback } from "react"; import { useTheme } from "../../hooks/useTheme"; import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { ThemeEditorPanel } from "./ThemeEditorPanel"; import { useThemeEditorStore } from "./themeEditorStore"; +// The host mounts above the router on every page, but the editor body only +// renders once a session opens; lazy-loading it keeps the editor UI out of +// the startup chunk. +const ThemeEditorPanel = lazy(() => + import("./ThemeEditorPanel").then((module) => ({ default: module.ThemeEditorPanel })), +); + /** * Renders the theme editor above the router. The editor paints its draft on * the live app, so it has to outlive the settings route: the point is to walk @@ -97,18 +103,20 @@ export function ThemeEditorHost() { const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null; return ( - { - if (!open) closeThemeEditor(); - }} - onSaved={handleSaved} - open - restoreTheme={refreshTheme} - seedName={session.seedName ?? undefined} - seedTheme={seedTheme} - /> + + { + if (!open) closeThemeEditor(); + }} + onSaved={handleSaved} + open + restoreTheme={refreshTheme} + seedName={session.seedName ?? undefined} + seedTheme={seedTheme} + /> + ); } diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts new file mode 100644 index 0000000000000..632e8ec8cf0dc --- /dev/null +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./chunkReloadGuard"; + +function createStorageStub(): Storage { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + removeItem: (key) => { + store.delete(key); + }, + clear: () => store.clear(), + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }; +} + +describe("reloadOnceForChunkLoadError", () => { + it("reloads on the first failure and lets the second one surface", () => { + const storage = createStorageStub(); + const reload = vi.fn(); + + expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + + expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("reloads again after a successful boot cleared the guard", () => { + const storage = createStorageStub(); + const reload = vi.fn(); + + reloadOnceForChunkLoadError(() => storage, reload); + clearChunkReloadGuard(() => storage); + + expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it("never reloads when storage is blocked, so a persistent failure cannot loop", () => { + const reload = vi.fn(); + const blocked = () => { + throw new DOMException("blocked", "SecurityError"); + }; + + expect(reloadOnceForChunkLoadError(blocked, reload)).toBe(false); + expect(reload).not.toHaveBeenCalled(); + expect(() => clearChunkReloadGuard(blocked)).not.toThrow(); + }); +}); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts new file mode 100644 index 0000000000000..c31127816b09a --- /dev/null +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -0,0 +1,39 @@ +// Split chunks are fetched lazily, so a deploy (or desktop server swap) +// between page load and a later fetch can 404 the old hashed assets. One +// reload picks up the fresh index.html. A sessionStorage flag keeps a +// persistent failure from becoming a reload loop, and a successful boot clears +// it so the next stale deploy gets its own single reload. +const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; + +/** + * Called from the `vite:preloadError` listener. Reloads at most once per + * failure streak and returns whether it did, so the caller knows whether to + * swallow the event or let the error surface through the normal paths. + */ +export function reloadOnceForChunkLoadError( + getStorage: () => Storage = () => window.sessionStorage, + reload: () => void = () => window.location.reload(), +): boolean { + let alreadyReloaded: boolean; + try { + const storage = getStorage(); + alreadyReloaded = storage.getItem(CHUNK_RELOAD_GUARD_KEY) === "1"; + if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, "1"); + } catch { + // Without storage the guard cannot survive a reload, so a persistent + // failure would loop forever. Let the error surface instead. + return false; + } + if (alreadyReloaded) return false; + reload(); + return true; +} + +/** Clears the guard after a successful boot so a later stale deploy can reload again. */ +export function clearChunkReloadGuard(getStorage: () => Storage = () => window.sessionStorage) { + try { + getStorage().removeItem(CHUNK_RELOAD_GUARD_KEY); + } catch { + // Blocked storage never held the flag. + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 3cdc8188b757a..78655785020ca 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,14 +1,10 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { ClerkProvider } from "@clerk/react"; -import { passkeys } from "@clerk/electron/passkeys"; -import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "./index.css"; import { isElectron } from "./env"; -import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; import { @@ -16,7 +12,7 @@ import { syncDocumentWindowControlsOverlayClass, } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; -import { clerkAppearance } from "./components/clerk/clerkAppearance"; +import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); @@ -30,26 +26,59 @@ if (isElectron) { const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; +// A failed split-chunk fetch usually means the hashed assets went stale under +// a deploy; one guarded reload picks up the fresh index.html. +let chunkLoadFailed = false; +let reloadScheduled = false; +window.addEventListener("vite:preloadError", (event) => { + chunkLoadFailed = true; + if (reloadOnceForChunkLoadError()) { + reloadScheduled = true; + event.preventDefault(); + } +}); + const app = ; -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - {clerkPublishableKey && hasCloudPublicConfig() ? ( - isElectron ? ( - - {app} - - ) : ( - - {app} - - ) - ) : ( - app - )} - , -); +// Managed auth is cloud-only, and the Electron Clerk provider bundles the full +// clerk-js runtime. Loading only the selected runtime as a split chunk keeps +// every Clerk byte out of the startup graph for local-mode users, and keeps +// the bundled clerk-js out of the browser build entirely. +const managedAuthShellModule = + clerkPublishableKey && hasCloudPublicConfig() + ? isElectron + ? import("./components/clerk/ElectronManagedAuthShell") + : import("./components/clerk/BrowserManagedAuthShell") + : null; + +// The index.html boot splash lives inside #root, and React's first commit +// clears it. Resolve everything that first commit needs, the selected +// managed-auth runtime and the initial route's split chunks, before +// rendering, so the splash holds until real UI paints instead of dropping to +// a blank window while chunks download. +void Promise.all([managedAuthShellModule?.then((module) => module.default) ?? null, router.load()]) + .then(([ManagedAuthShell]) => { + // A route chunk failure still resolves router.load(): the error is parked in + // the lazy component and surfaces through the route error boundary. Skip the + // paint when a reload is on its way, and only re-arm the guard after a boot + // that fetched every chunk it asked for. + if (reloadScheduled) return; + if (!chunkLoadFailed) clearChunkReloadGuard(); + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + {ManagedAuthShell && clerkPublishableKey ? ( + {app} + ) : ( + app + )} + , + ); + }) + .catch((error: unknown) => { + // The auth shell chunk failed and the guarded reload is spent. Say so + // instead of leaving the splash up forever. + if (reloadScheduled) return; + console.error("T3 Code failed to load its startup chunks.", error); + const bootShell = document.getElementById("boot-shell"); + if (bootShell) bootShell.textContent = "T3 Code could not load. Reload to try again."; + }); diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 86ba9d69a1736..4362dd3d70bac 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -7,6 +7,10 @@ export function getRouter(history: RouterHistory) { routeTree, history, context: {}, + // Route components are split chunks (autoCodeSplitting in vite.config); + // fetching them on hover/focus intent hides the load from the first + // settings or pull-request navigation. + defaultPreload: "intent", }); } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6ee5de587b9e6..399f1bee497a2 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -156,7 +156,10 @@ export default defineConfig(() => { assetsInclude: ["**/*.wasm"], plugins: [ devCompressionPlugin(), - tanstackRouter(), + // Route components load as split chunks so settings, pull-request, and + // usage code stay out of the cold-start payload; the router prefetches + // them on navigation intent (see getRouter's defaultPreload). + tanstackRouter({ autoCodeSplitting: true }), react(), babel({ // We need to be explicit about the parser options after moving to @vitejs/plugin-react v6.0.0 From b2f25d390a8546e42eb6186115b673a53b8c38dc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 19:08:38 -0700 Subject: [PATCH 060/136] feat(desktop): update the desktop app on remote Macs from the Update button (#6554) Use a prepare and commit handoff for remote desktop updates. Retry lost commits, require the prepared version after reconnect, and recover backends and windows when installation fails. Built with GPT-5.6 Sol in Codex. Co-authored-by: Claude Fable 5 Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com> --- apps/desktop/src/app/DesktopApp.ts | 2 + apps/desktop/src/app/DesktopLifecycle.test.ts | 11 +- apps/desktop/src/app/DesktopLifecycle.ts | 13 +- .../src/backend/DesktopBackendManager.test.ts | 4 + .../src/backend/DesktopBackendPool.test.ts | 4 + .../DesktopTelemetryPublisher.test.ts | 84 +- .../telemetry/DesktopTelemetryPublisher.ts | 50 +- .../src/updates/DesktopRemoteUpdates.test.ts | 842 ++++++++++++++++++ .../src/updates/DesktopRemoteUpdates.ts | 461 ++++++++++ .../src/updates/DesktopUpdates.test.ts | 350 +++----- apps/desktop/src/updates/DesktopUpdates.ts | 312 +++++-- .../src/updates/remoteUpdateFlow.test.ts | 218 +++++ apps/desktop/src/updates/remoteUpdateFlow.ts | 115 +++ .../desktop/src/updates/updatesTestHarness.ts | 242 +++++ .../src/window/DesktopApplicationMenu.test.ts | 4 + apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/cloud/selfUpdate.test.ts | 34 + apps/server/src/cloud/selfUpdate.ts | 16 +- .../desktopUpdate/DesktopAppUpdate.test.ts | 247 +++++ .../src/desktopUpdate/DesktopAppUpdate.ts | 219 +++++ .../src/environment/ServerEnvironment.test.ts | 37 + .../src/environment/ServerEnvironment.ts | 11 +- .../DesktopTelemetryReceiver.ts | 62 ++ apps/server/src/server.ts | 9 +- apps/server/src/ws.ts | 6 + apps/web/src/components/ChatView.tsx | 15 +- .../components/ServerUpdateAction.test.tsx | 43 + .../web/src/components/ServerUpdateAction.tsx | 27 +- .../settings/ConnectionsSettings.tsx | 7 +- apps/web/src/versionSkew.test.ts | 22 + apps/web/src/versionSkew.ts | 8 + docs/internals/server-updates.md | 12 +- docs/user/updating.md | 10 +- .../src/connection/registry.test.ts | 28 + .../client-runtime/src/state/runtime.test.ts | 51 ++ .../client-runtime/src/state/server.test.ts | 112 +++ packages/client-runtime/src/state/server.ts | 166 +++- packages/contracts/src/environment.ts | 11 +- packages/contracts/src/resourceTelemetry.ts | 58 ++ packages/contracts/src/rpc.ts | 9 + packages/contracts/src/server.ts | 7 + 41 files changed, 3596 insertions(+), 344 deletions(-) create mode 100644 apps/desktop/src/updates/DesktopRemoteUpdates.test.ts create mode 100644 apps/desktop/src/updates/DesktopRemoteUpdates.ts create mode 100644 apps/desktop/src/updates/remoteUpdateFlow.test.ts create mode 100644 apps/desktop/src/updates/remoteUpdateFlow.ts create mode 100644 apps/desktop/src/updates/updatesTestHarness.ts create mode 100644 apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts create mode 100644 apps/server/src/desktopUpdate/DesktopAppUpdate.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 52836582f9e34..d5a8ac3b7836c 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -27,6 +27,7 @@ import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; +import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; @@ -292,6 +293,7 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; + yield* DesktopRemoteUpdates.listen; yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index f5ff3d5f6af68..0086edf20bf98 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -102,6 +102,7 @@ describe("DesktopLifecycle", () => { for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { it.effect(`lets the updater's quit event proceed on ${platform}`, () => { const appListeners = new Map void>(); + let windowsDestroyed = false; const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { platform, isDevelopment: false, @@ -110,7 +111,13 @@ describe("DesktopLifecycle", () => { const layer = DesktopLifecycle.layer.pipe( Layer.provideMerge(makeElectronAppLayer(appListeners)), Layer.provideMerge(electronThemeLayer), - Layer.provideMerge(makeElectronWindowLayer()), + Layer.provideMerge( + makeElectronWindowLayer( + Effect.sync(() => { + windowsDestroyed = true; + }), + ), + ), Layer.provideMerge(makeDesktopWindowLayer()), Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), @@ -123,6 +130,7 @@ describe("DesktopLifecycle", () => { yield* lifecycle.register; appListeners.get("before-quit-for-update")?.(); + yield* Effect.yieldNow; let prevented = false; const event = { @@ -136,6 +144,7 @@ describe("DesktopLifecycle", () => { prevented, "cancelling this event prevents the updater from completing its relaunch", ); + assert.isTrue(windowsDestroyed); const state = yield* DesktopState.DesktopState; assert.isTrue(yield* Ref.get(state.quitting)); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 6a98e59eb870e..0a0cc6dca9339 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -189,6 +189,7 @@ export const make = DesktopLifecycle.of({ }), register: Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronWindow = yield* ElectronWindow.ElectronWindow; const electronApp = yield* ElectronApp.ElectronApp; const electronTheme = yield* ElectronTheme.ElectronTheme; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -206,8 +207,16 @@ export const make = DesktopLifecycle.of({ // Cancelling the following app "before-quit" event breaks that sequence, // most visibly on macOS where the native updater performs the relaunch. updaterQuitAllowed = true; - void runEffect( - logLifecycleInfo("allowing updater-controlled quit").pipe( + // This event is synchronous and the updater's quit proceeds as soon as + // the listener returns, so a forked destroyAll would race the quit + // and windows could still be open when the process exits (visible on + // macOS). Destroy them inline. + Effect.runSyncWith(context)( + electronWindow.destroyAll.pipe( + Effect.andThen(logLifecycleInfo("allowing updater-controlled quit")), + Effect.catchCause((cause) => + logLifecycleError("failed to destroy windows before updater quit", { cause }), + ), Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), ), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index e6c9a2c17c183..53ccf5a756eb7 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -167,6 +167,10 @@ function makeTestInstance(input: MakeInstanceInput) { handleControlForSource: (_sourceId, message) => (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message), removeControlSource: () => Effect.void, + publishUpdateReport: () => Effect.void, + updateRequests: Stream.empty, + updateCommits: Stream.empty, + updateCancellations: Stream.empty, ...input.desktopTelemetryPublisher, }), DesktopWslEnvironment.layerTest( diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 97d4359e1663b..5fe02c8d17289 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -73,6 +73,10 @@ function makePoolLayer( handleControl: () => Effect.void, handleControlForSource: () => Effect.void, removeControlSource: () => Effect.void, + publishUpdateReport: () => Effect.void, + updateRequests: Stream.empty, + updateCommits: Stream.empty, + updateCancellations: Stream.empty, }), Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { resolvePrimary: Effect.die("unexpected primary config resolve"), diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 3d912a5d5aa82..d705252ef588e 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -157,8 +157,11 @@ describe("DesktopTelemetryPublisher", () => { decodeMessage(decoder.decode(bytes).trim()), ); - assert.equal(messages[0]?.type, "desktopTelemetryHello"); - assert.equal(messages[0]?.electronPid, process.pid); + const hello = messages[0]; + if (hello?.type !== "desktopTelemetryHello") { + return assert.fail("Expected the first telemetry message to be the hello."); + } + assert.equal(hello.electronPid, process.pid); const initialSnapshot = messages[1]; if (initialSnapshot?.type !== "desktopTelemetry") { return assert.fail("Expected the second telemetry message to be a snapshot."); @@ -386,4 +389,81 @@ describe("DesktopTelemetryPublisher", () => { }).pipe(Effect.provide(layer)); }), ); + + it.effect("routes requestDesktopUpdate control messages and replays update reports", () => + Effect.gen(function* () { + const powerLayer = Layer.succeed( + ElectronPowerMonitor.ElectronPowerMonitor, + ElectronPowerMonitor.ElectronPowerMonitor.of({ + isOnBatteryPower: Effect.succeed(false), + getSystemIdleTime: Effect.succeed(0), + getSystemIdleState: () => Effect.succeed("active"), + getCurrentThermalState: Effect.succeed("nominal"), + onSimpleEvent: () => Effect.void, + onThermalStateChange: () => Effect.void, + onSpeedLimitChange: () => Effect.void, + }), + ); + const layer = DesktopTelemetryPublisher.layer.pipe( + Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)), + ); + + yield* Effect.gen(function* () { + const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; + + const requestFiber = yield* Stream.runHead(publisher.updateRequests).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* publisher.handleControlForSource("test", { + version: 1, + type: "requestDesktopUpdate", + requestId: "req-9", + }); + const received = yield* Fiber.join(requestFiber); + assert.equal(Option.getOrThrow(received).requestId, "req-9"); + + const report = { + version: 1, + type: "desktopUpdateStatus", + outcome: "up-to-date", + state: { + enabled: true, + status: "up-to-date", + channel: "latest", + currentVersion: "1.2.3", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + releaseNotes: [], + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + omittedReleaseCount: 0, + }, + } as const; + yield* publisher.publishUpdateReport(report); + + // A subscriber that attaches after the publish (the backend spawned + // by a relaunch) still sees the latest report replayed. + const decoder = new TextDecoder(); + const decodeMessage = Schema.decodeUnknownEffect( + Schema.fromJsonString(DesktopHostTelemetryMessage), + ); + const replayed = yield* publisher.encoded.pipe( + Stream.mapEffect((bytes) => decodeMessage(decoder.decode(bytes).trim())), + Stream.filter((message) => message.type === "desktopUpdateStatus"), + Stream.runHead, + ); + const replayedReport = Option.getOrThrow(replayed); + if (replayedReport.type !== "desktopUpdateStatus") { + return assert.fail("Expected a desktop update status report."); + } + assert.equal(replayedReport.outcome, "up-to-date"); + assert.equal(replayedReport.state.currentVersion, "1.2.3"); + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 9c17ae514d223..3f56ace15fb1c 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -2,6 +2,10 @@ import { DesktopHostTelemetryMessage, type DesktopHostTelemetrySnapshot, type DesktopTelemetryControlMessage, + type DesktopTelemetryCancelDesktopUpdate, + type DesktopTelemetryCommitDesktopUpdate, + type DesktopTelemetryRequestDesktopUpdate, + type DesktopUpdateStatusReport, type HostPowerSnapshot, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -65,6 +69,14 @@ export class DesktopTelemetryPublisher extends Context.Service< message: DesktopTelemetryControlMessage, ) => Effect.Effect; readonly removeControlSource: (sourceId: string) => Effect.Effect; + /** Sends the report to the attached backend and replays the latest one + to backends that attach later (including the one spawned after a + relaunch). */ + readonly publishUpdateReport: (report: DesktopUpdateStatusReport) => Effect.Effect; + /** Update requests received over the control channel. Single consumer. */ + readonly updateRequests: Stream.Stream; + readonly updateCommits: Stream.Stream; + readonly updateCancellations: Stream.Stream; } >()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {} @@ -160,6 +172,11 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { const latest = yield* Ref.make(Option.none()); const changes = yield* PubSub.sliding(8); const sequence = yield* Ref.make(0); + const latestUpdateReport = yield* Ref.make(Option.none()); + const updateReportChanges = yield* PubSub.sliding(16); + const updateRequestQueue = yield* Queue.unbounded(); + const updateCommitQueue = yield* Queue.unbounded(); + const updateCancellationQueue = yield* Queue.unbounded(); const offer = (event: PowerEvent): void => { Queue.offerUnsafe(powerEvents, event); @@ -324,6 +341,12 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { active: Duration.millis(message.activeIntervalMs), idle: Duration.millis(message.idleIntervalMs), }).pipe(Effect.andThen(Queue.offer(sampleTriggers, undefined)), Effect.asVoid); + case "requestDesktopUpdate": + return Queue.offer(updateRequestQueue, message).pipe(Effect.asVoid); + case "commitDesktopUpdate": + return Queue.offer(updateCommitQueue, message).pipe(Effect.asVoid); + case "cancelDesktopUpdate": + return Queue.offer(updateCancellationQueue, message).pipe(Effect.asVoid); } }; const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = ( @@ -357,15 +380,36 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { ); }), ); + const updateReports = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(updateReportChanges); + const initial = yield* Ref.get(latestUpdateReport); + return Stream.concat( + Option.match(initial, { + onNone: () => Stream.empty, + onSome: Stream.make, + }), + Stream.fromSubscription(subscription), + ); + }), + ); const encoded = Stream.concat( Stream.make({ version: 1, type: "desktopTelemetryHello", electronPid: process.pid, } as const), - snapshots, + Stream.merge(snapshots, updateReports), ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`))); + const publishUpdateReport: DesktopTelemetryPublisher["Service"]["publishUpdateReport"] = ( + report, + ) => + Ref.set(latestUpdateReport, Option.some(report)).pipe( + Effect.andThen(PubSub.publish(updateReportChanges, report)), + Effect.asVoid, + ); + return DesktopTelemetryPublisher.of({ latest: Ref.get(latest), changes: Stream.fromPubSub(changes), @@ -373,6 +417,10 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { handleControl, handleControlForSource, removeControlSource, + publishUpdateReport, + updateRequests: Stream.fromQueue(updateRequestQueue), + updateCommits: Stream.fromQueue(updateCommitQueue), + updateCancellations: Stream.fromQueue(updateCancellationQueue), }); }); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts new file mode 100644 index 0000000000000..0f4cb970da2c1 --- /dev/null +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -0,0 +1,842 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { + DesktopTelemetryRequestDesktopUpdate, + DesktopTelemetryCommitDesktopUpdate, + DesktopTelemetryCancelDesktopUpdate, + DesktopUpdateStatusReport, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopRemoteUpdates from "./DesktopRemoteUpdates.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; +import { makeHarness } from "./updatesTestHarness.ts"; + +// The remote flow hops between the test runtime's fibers and the updater's +// runPromise-driven event handlers, so settling needs real microtask turns, +// not just fiber yields. +const settle = Effect.gen(function* () { + for (let i = 0; i < 20; i += 1) { + yield* Effect.yieldNow; + yield* Effect.promise(() => Promise.resolve()); + } +}); + +const request = (requestId: string): DesktopTelemetryRequestDesktopUpdate => ({ + version: 1, + type: "requestDesktopUpdate", + requestId, +}); + +function runRemoteUpdatesTest( + harness: ReturnType, + body: (context: { + readonly reports: DesktopUpdateStatusReport[]; + readonly requests: Queue.Queue; + readonly commits: Queue.Queue; + readonly cancellations: Queue.Queue; + }) => Effect.Effect, +) { + return Effect.scoped( + Effect.gen(function* () { + const requests = yield* Queue.unbounded(); + const commits = yield* Queue.unbounded(); + const cancellations = yield* Queue.unbounded(); + const reports: DesktopUpdateStatusReport[] = []; + const publisher = DesktopTelemetryPublisher.DesktopTelemetryPublisher.of({ + latest: Effect.succeedNone, + changes: Stream.empty, + encoded: Stream.empty, + handleControl: () => Effect.void, + handleControlForSource: () => Effect.void, + removeControlSource: () => Effect.void, + publishUpdateReport: (report) => + Effect.sync(() => { + reports.push(report); + }), + updateRequests: Stream.fromQueue(requests), + updateCommits: Stream.fromQueue(commits), + updateCancellations: Stream.fromQueue(cancellations), + }); + + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* DesktopRemoteUpdates.listen.pipe( + Effect.provideService(DesktopTelemetryPublisher.DesktopTelemetryPublisher, publisher), + ); + yield* settle; + yield* body({ reports, requests, commits, cancellations }); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); +} + +function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateStatusReport[] { + return reports.filter((report) => report.outcome !== undefined); +} + +describe("DesktopRemoteUpdates", () => { + it.effect("drives check, download, and install with no local confirmation", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-1")); + yield* settle; + assert.equal(harness.checkCount(), 1); + + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + const terminals = terminalReports(reports); + assert.equal(terminals.length, 1); + assert.equal(terminals[0]?.outcome, "ready-to-install"); + assert.equal(terminals[0]?.requestId, "req-1"); + assert.equal(harness.quitAndInstalls(), 0); + + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-1", + }); + yield* settle; + assert.equal(harness.quitAndInstalls(), 1); + + // The mirror stamped the in-run state changes with the request id. + const statuses = reports + .filter((report) => report.requestId === "req-1") + .map((report) => report.state.status); + assert.include(statuses, "available"); + assert.include(statuses, "downloaded"); + }), + ); + }); + + it.effect("reports a failed outcome when quitAndInstall fails", () => { + const harness = makeHarness({ + quitAndInstall: Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("spawn failed"), + }), + ), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-4")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-4", + }); + yield* settle; + + // Install failures reduce to status "downloaded" + errorContext + // "install"; the prepared result is followed by the commit failure. + const terminals = terminalReports(reports); + assert.deepEqual( + terminals.map((report) => report.outcome), + ["ready-to-install", "failed"], + ); + assert.equal(terminals[1]?.state.errorContext, "install"); + }), + ); + }); + + it.effect("joins an in-progress install on commit instead of failing the token", () => { + const installStarted = Deferred.makeUnsafe(); + const releaseInstall = Deferred.makeUnsafe(); + const harness = makeHarness({ + stopBackend: Deferred.succeed(installStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseInstall)), + ), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, request("req-join-commit")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + + // A local install takes the updater reservation and starts shutdown. + const localInstall = yield* updates.install.pipe(Effect.forkChild); + yield* Deferred.await(installStarted); + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-join-commit", + }); + yield* settle; + + // No "failed" marker: that install relaunches the app and the + // client proves the handoff by reconnecting on the target version. + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 0); + + yield* Deferred.succeed(releaseInstall, undefined); + yield* Fiber.join(localInstall); + }), + ); + }); + + it.effect("does not join a normal app quit as an update install", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + yield* Queue.offer(requests, request("req-normal-quit")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + yield* Ref.set(desktopState.quitting, true); + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-normal-quit", + }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install", "failed"], + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + + it.effect("does not misread a lingering install error as a failed retry", () => { + let installAttempts = 0; + const harness = makeHarness({ + quitAndInstall: Effect.suspend(() => { + installAttempts += 1; + return installAttempts === 1 + ? Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("first attempt failed"), + }), + ) + : Effect.void; + }), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-5")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-5", + }); + yield* settle; + // First run failed; state is "downloaded" with a lingering + // errorContext "install". The retry succeeds and must not report + // that leftover as a fresh failure. + yield* Queue.offer(requests, request("req-6")); + yield* settle; + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-6", + }); + yield* settle; + + const retryTerminals = terminalReports(reports).filter( + (report) => report.requestId === "req-6", + ); + assert.deepEqual( + retryTerminals.map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 2); + }), + ); + }); + + it.effect("ignores an unrelated updater event while an install is starting", () => { + const stopStarted = Deferred.makeUnsafe(); + const releaseStop = Deferred.makeUnsafe(); + const harness = makeHarness({ + stopBackend: Deferred.succeed(stopStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStop)), + ), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-late-event")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-late-event", + }); + yield* Deferred.await(stopStarted); + harness.emit("download-progress", { percent: 90 }); + yield* settle; + yield* Deferred.succeed(releaseStop, undefined); + yield* settle; + + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-late-event") + .map((report) => report.outcome), + ["ready-to-install"], + ); + }), + ); + }); + + it.effect("retries a download refused while the check still holds the reservation", () => { + // electron-updater emits update-available from inside checkForUpdates, + // before the check action releases its reservation. The download the + // remote flow forks in response is refused and must be retried once the + // reservation frees up, without burning a download attempt. + const releaseCheck = Deferred.makeUnsafe(); + const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-8")); + yield* settle; + assert.equal(harness.checkCount(), 1); + + // Fire "available" while the check reservation is still held. + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + assert.equal(harness.downloadCount(), 0); + + yield* Deferred.succeed(releaseCheck, undefined); + yield* settle; + yield* TestClock.adjust(Duration.millis(300)); + yield* settle; + assert.equal(harness.downloadCount(), 1); + + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + + it.effect("waits for the download reservation before reporting prepared", () => { + // update-downloaded fires from inside downloadUpdate. If the flow + // reported "installing" right then, install would be refused for the + // held reservation after the irrevocable terminal already went out. + const releaseDownload = Deferred.makeUnsafe(); + const harness = makeHarness({ downloadUpdate: Deferred.await(releaseDownload) }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-10")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + assert.deepEqual(terminalReports(reports), []); + assert.equal(harness.quitAndInstalls(), 0); + + yield* Deferred.succeed(releaseDownload, undefined); + yield* settle; + yield* TestClock.adjust(Duration.millis(300)); + yield* settle; + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + + it.effect("joins an install that is already tearing the app down", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + // Another install already owns the shutdown. + yield* Ref.set(desktopState.quitting, true); + + yield* Queue.offer(requests, request("req-9")); + yield* settle; + + // Report "installing" once, no "failed" after the refusal: that + // install will relaunch the app and this request rides along. + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + + it.effect("does not run the installer twice for a repeated commit", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-repeat")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + const commit = { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-repeat", + } as const; + yield* Queue.offer(commits, commit); + yield* settle; + yield* Queue.offer(commits, commit); + yield* settle; + + assert.equal(harness.quitAndInstalls(), 1); + }), + ); + }); + + it.effect("waits for a background check before installing a prepared update", () => { + const backgroundCheckStarted = Deferred.makeUnsafe(); + const releaseBackgroundCheck = Deferred.makeUnsafe(); + let checks = 0; + const harness = makeHarness({ + checkForUpdates: Effect.suspend(() => { + checks += 1; + return checks === 2 + ? Deferred.succeed(backgroundCheckStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseBackgroundCheck)), + ) + : Effect.void; + }), + }); + + return runRemoteUpdatesTest(harness, ({ requests, commits }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, request("req-background-check")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + const backgroundCheck = yield* updates.check("poll").pipe(Effect.forkChild); + yield* Deferred.await(backgroundCheckStarted); + assert.equal((yield* updates.getState).status, "checking"); + + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-background-check", + }); + yield* settle; + assert.equal(harness.quitAndInstalls(), 0); + + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* Deferred.succeed(releaseBackgroundCheck, undefined); + yield* Fiber.join(backgroundCheck); + yield* settle; + assert.equal(harness.quitAndInstalls(), 1); + }), + ); + }); + + it.effect("fails a prepared install when its background check stays blocked", () => { + const backgroundCheckStarted = Deferred.makeUnsafe(); + const releaseBackgroundCheck = Deferred.makeUnsafe(); + let checks = 0; + const harness = makeHarness({ + checkForUpdates: Effect.suspend(() => { + checks += 1; + return checks === 2 + ? Deferred.succeed(backgroundCheckStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseBackgroundCheck)), + ) + : Effect.void; + }), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, request("req-blocked-background-check")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + const backgroundCheck = yield* updates.check("poll").pipe(Effect.forkChild); + yield* Deferred.await(backgroundCheckStarted); + const commit = { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-blocked-background-check", + } as const; + yield* Queue.offer(commits, commit); + yield* settle; + yield* TestClock.adjust(Duration.seconds(91)); + yield* settle; + + assert.equal(harness.quitAndInstalls(), 0); + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-blocked-background-check") + .map((report) => report.outcome), + ["ready-to-install", "failed"], + ); + + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* Deferred.succeed(releaseBackgroundCheck, undefined); + yield* Fiber.join(backgroundCheck); + yield* settle; + assert.equal(harness.quitAndInstalls(), 0); + + yield* Queue.offer(commits, commit); + yield* settle; + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-blocked-background-check") + .map((report) => report.outcome), + ["ready-to-install", "failed", "failed"], + ); + }), + ); + }); + + it.effect("rejects a new preparation while an install commit is active", () => { + const installStarted = Deferred.makeUnsafe(); + const releaseInstall = Deferred.makeUnsafe(); + const harness = makeHarness({ + quitAndInstall: Deferred.succeed(installStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseInstall)), + Effect.andThen( + Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("installer refused"), + }), + ), + ), + ), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-active")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-active", + }); + yield* Deferred.await(installStarted); + + yield* Queue.offer(requests, request("req-overlap")); + yield* settle; + const overlap = terminalReports(reports).find( + (report) => report.requestId === "req-overlap", + ); + assert.equal(overlap?.outcome, "failed"); + assert.equal(overlap?.reason, "A prepared desktop update is already in progress."); + + yield* Deferred.succeed(releaseInstall, undefined); + yield* settle; + }), + ); + }); + + it.effect("cancels an active preparation so the next request can run", () => { + const releaseCheck = Deferred.makeUnsafe(); + const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); + + return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-cancel")); + yield* settle; + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-cancel", + }); + yield* settle; + yield* Deferred.succeed(releaseCheck, undefined); + yield* Queue.offer(requests, request("req-next")); + yield* settle; + + assert.equal(harness.checkCount(), 2); + }), + ); + }); + + it.effect("remembers a cancellation that arrives before its request starts", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-early-cancel", + }); + yield* Queue.offer(requests, request("req-early-cancel")); + yield* settle; + + assert.equal(harness.checkCount(), 0); + + yield* Queue.offer(requests, request("req-after-early-cancel")); + yield* settle; + assert.equal(harness.checkCount(), 1); + }), + ); + }); + + it.effect("keeps every queued cancellation until its request starts", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => + Effect.gen(function* () { + for (let index = 0; index < 40; index += 1) { + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: `req-queued-cancel-${index}`, + }); + } + yield* settle; + for (let index = 0; index < 40; index += 1) { + yield* Queue.offer(requests, request(`req-queued-cancel-${index}`)); + } + yield* settle; + assert.equal(harness.checkCount(), 0); + + yield* Queue.offer(requests, request("req-after-queued-cancels")); + yield* settle; + assert.equal(harness.checkCount(), 1); + }), + ); + }); + + it.effect("does not install after cancellation wins the commit claim", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-cancel-before-commit")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-cancel-before-commit", + }); + yield* settle; + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-cancel-before-commit", + }); + yield* settle; + + assert.equal(harness.quitAndInstalls(), 0); + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-cancel-before-commit") + .map((report) => report.outcome), + ["ready-to-install", "failed"], + ); + }), + ); + }); + + it.effect("retains an install failure after cancellation loses the commit claim", () => { + const installStarted = Deferred.makeUnsafe(); + const releaseInstall = Deferred.makeUnsafe(); + const harness = makeHarness({ + quitAndInstall: Deferred.succeed(installStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseInstall)), + Effect.andThen( + Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("installer refused"), + }), + ), + ), + ), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-cancel-after-commit")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + const commit = { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-cancel-after-commit", + } as const; + yield* Queue.offer(commits, commit); + yield* Deferred.await(installStarted); + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-cancel-after-commit", + }); + yield* settle; + yield* Deferred.succeed(releaseInstall, undefined); + yield* settle; + yield* Queue.offer(commits, commit); + yield* settle; + + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-cancel-after-commit") + .map((report) => report.outcome), + ["ready-to-install", "failed", "failed"], + ); + }), + ); + }); + + it.effect("expires an uncommitted preparation", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-stale")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + yield* TestClock.setTime(Duration.toMillis(Duration.minutes(6))); + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-stale", + }); + yield* settle; + + const outcomes = terminalReports(reports) + .filter((report) => report.requestId === "req-stale") + .map((report) => report.outcome); + assert.deepEqual(outcomes, ["ready-to-install", "failed"]); + }), + ); + }); + + it.effect("reports up-to-date without installing when there is no update", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-2")); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + + const terminals = terminalReports(reports); + assert.equal(terminals.length, 1); + assert.equal(terminals[0]?.outcome, "up-to-date"); + assert.equal(terminals[0]?.requestId, "req-2"); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + + it.effect("fails fast with the disabled reason when updates are off", () => { + const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-3")); + yield* settle; + + const terminals = terminalReports(reports); + assert.equal(terminals.length, 1); + assert.equal(terminals[0]?.outcome, "failed"); + assert.equal( + terminals[0]?.reason, + "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.", + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); +}); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts new file mode 100644 index 0000000000000..31e3c5af52605 --- /dev/null +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -0,0 +1,461 @@ +import type { + DesktopTelemetryRequestDesktopUpdate, + DesktopUpdateRemoteOutcome, + DesktopUpdateState, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; +import { + nextRemoteDesktopUpdateStep, + normalizeRemoteUpdateReason, + type RemoteDesktopUpdateAttempts, +} from "./remoteUpdateFlow.ts"; + +const { logInfo, logError } = DesktopObservability.makeComponentLogger("desktop-remote-updates"); + +/** Pause before retrying an action the updater refused for a held reservation. */ +const ACTION_RETRY_DELAY = Duration.millis(250); +const PREPARED_UPDATE_TTL = Duration.minutes(5); + +interface PreparedUpdate { + readonly requestId: string; + readonly downloadedVersion: string; + readonly status: "prepared" | "committing" | "failed"; + readonly failureReason?: string; + readonly expiresAt: number; +} + +type CommitClaim = + | { readonly _tag: "invalid" } + | { readonly _tag: "failed"; readonly prepared: PreparedUpdate } + | { readonly _tag: "committing" } + | { readonly _tag: "claimed"; readonly prepared: PreparedUpdate }; + +/** + * Server-triggered desktop updates. Mirrors updater state, prepares downloads, + * cancels abandoned preparations, and commits installs after the remote client + * confirms that it received the preparation token. + */ +export const listen: Effect.Effect< + void, + never, + DesktopUpdates.DesktopUpdates | DesktopTelemetryPublisher.DesktopTelemetryPublisher | Scope.Scope +> = Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; + const activeRequestIdRef = yield* Ref.make(Option.none()); + const requestControlRef = yield* Ref.make<{ + readonly active: Option.Option<{ + readonly requestId: string; + readonly signal: Deferred.Deferred; + }>; + readonly cancelledBeforeStart: ReadonlyArray; + }>({ active: Option.none(), cancelledBeforeStart: [] }); + const preparedUpdateRef = yield* Ref.make(Option.none()); + + const publishReport = ( + state: DesktopUpdateState, + terminal?: { readonly outcome: DesktopUpdateRemoteOutcome; readonly reason?: string }, + explicitRequestId?: string, + ): Effect.Effect => { + const reason = normalizeRemoteUpdateReason(terminal?.reason); + return Ref.get(activeRequestIdRef).pipe( + Effect.flatMap((requestId) => + publisher.publishUpdateReport({ + version: 1, + type: "desktopUpdateStatus", + ...(explicitRequestId !== undefined + ? { requestId: explicitRequestId } + : Option.isSome(requestId) + ? { requestId: requestId.value } + : {}), + ...(terminal === undefined ? {} : { outcome: terminal.outcome }), + ...(reason ? { reason } : {}), + state, + }), + ), + ); + }; + + const recordPreparedFailure = (requestId: string, reason: string) => + Ref.modify(preparedUpdateRef, (prepared) => { + if ( + Option.isNone(prepared) || + prepared.value.requestId !== requestId || + prepared.value.status === "failed" + ) { + return [false, prepared] as const; + } + return [ + true, + Option.some({ ...prepared.value, status: "failed" as const, failureReason: reason }), + ] as const; + }); + + const clearActiveRequest = (requestId: string) => + Ref.update(activeRequestIdRef, (active) => + Option.isSome(active) && active.value === requestId ? Option.none() : active, + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const { latest, changes } = yield* updates.subscribe; + yield* publishReport(latest); + yield* Stream.runForEach(changes, (state) => + Effect.gen(function* () { + yield* publishReport(state); + const prepared = yield* Ref.get(preparedUpdateRef); + if ( + Option.isSome(prepared) && + prepared.value.status === "committing" && + state.errorContext === "install" + ) { + const reason = state.message ?? "The desktop app failed to install the update."; + if (yield* recordPreparedFailure(prepared.value.requestId, reason)) { + yield* publishReport(state, { outcome: "failed", reason }, prepared.value.requestId); + } + } + }), + ); + }), + ).pipe(Effect.forkScoped); + + const handleRequest = (request: DesktopTelemetryRequestDesktopUpdate): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const cancellation = yield* Deferred.make(); + const shouldStart = yield* Ref.modify(requestControlRef, (control) => { + if (control.cancelledBeforeStart.includes(request.requestId)) { + return [ + false, + { + ...control, + cancelledBeforeStart: control.cancelledBeforeStart.filter( + (requestId) => requestId !== request.requestId, + ), + }, + ] as const; + } + return [ + true, + { + ...control, + active: Option.some({ requestId: request.requestId, signal: cancellation }), + }, + ] as const; + }); + if (!shouldStart) return; + + const now = yield* Clock.currentTimeMillis; + const prepared = yield* Ref.modify(preparedUpdateRef, (current) => + Option.isSome(current) && + current.value.status === "prepared" && + current.value.expiresAt <= now + ? ([Option.none(), Option.none()] as const) + : ([current, current] as const), + ); + if (Option.isSome(prepared) && prepared.value.status !== "failed") { + yield* publishReport( + yield* updates.getState, + { + outcome: "failed", + reason: "A prepared desktop update is already in progress.", + }, + request.requestId, + ); + return; + } + yield* Ref.set(activeRequestIdRef, Option.some(request.requestId)); + yield* logInfo("remote update requested", { requestId: request.requestId }); + const { latest, changes } = yield* updates.subscribe; + const disabledReason = Option.getOrNull(yield* updates.disabledReason); + let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; + // The updater admits one action at a time. A state event can land + // while the action that produced it still holds the reservation + // (e.g. "available" before the check releases), so a forked action + // can be refused with no later state event to retry on. Rejected + // actions re-enqueue their state here after a short pause so the + // step runs again once the reservation is free. + const retries = yield* Queue.unbounded(); + const retryLater = (state: DesktopUpdateState) => + Effect.sleep(ACTION_RETRY_DELAY).pipe( + Effect.andThen(Queue.offer(retries, state)), + Effect.asVoid, + Effect.forkScoped, + ); + + // Returns true when the run reached a terminal outcome. + const step = (state: DesktopUpdateState): Effect.Effect => + Effect.gen(function* () { + const next = nextRemoteDesktopUpdateStep(state, attempts, disabledReason); + switch (next.action) { + case "wait": + return false; + // Counters increment before the action so the state event the + // action produces already sees it; a refusal for a held + // reservation rolls the count back, since it was not a try. + case "check": + attempts = { ...attempts, checks: attempts.checks + 1 }; + yield* updates.check("remote-update").pipe( + Effect.flatMap((result) => { + if (result.checked) return Effect.void; + attempts = { ...attempts, checks: attempts.checks - 1 }; + return retryLater(state); + }), + Effect.forkScoped, + ); + return false; + case "download": + attempts = { ...attempts, downloads: attempts.downloads + 1 }; + yield* updates.download.pipe( + Effect.flatMap((result) => { + if (result.accepted) return Effect.void; + attempts = { ...attempts, downloads: attempts.downloads - 1 }; + return retryLater(state); + }), + Effect.forkScoped, + ); + return false; + case "install": { + // The download event fires before its action releases the + // updater reservation. Wait until the prepared install can + // be committed by the client in a separate RPC. + if (yield* updates.isActionActive) { + yield* retryLater(state); + return false; + } + if (state.downloadedVersion === null) { + yield* publishReport( + state, + { + outcome: "failed", + reason: "The desktop app lost the downloaded update.", + }, + request.requestId, + ); + return true; + } + yield* Ref.set( + preparedUpdateRef, + Option.some({ + requestId: request.requestId, + downloadedVersion: state.downloadedVersion, + status: "prepared", + expiresAt: + (yield* Clock.currentTimeMillis) + Duration.toMillis(PREPARED_UPDATE_TTL), + }), + ); + yield* publishReport(state, { outcome: "ready-to-install" }, request.requestId); + yield* logInfo("remote update prepared", { requestId: request.requestId }); + return true; + } + case "done": + yield* publishReport( + state, + { + outcome: next.outcome, + ...(next.reason === undefined ? {} : { reason: next.reason }), + }, + request.requestId, + ); + yield* logInfo("remote update finished", { + requestId: request.requestId, + outcome: next.outcome, + reason: next.reason ?? null, + }); + return true; + } + }); + + yield* Effect.raceFirst( + Effect.gen(function* () { + if (yield* step(latest)) return; + yield* Stream.merge(changes, Stream.fromQueue(retries)).pipe( + Stream.mapEffect(step), + Stream.takeUntil((done) => done), + Stream.runDrain, + ); + }), + Deferred.await(cancellation), + ); + }), + ).pipe( + Effect.ensuring( + Effect.all( + [ + clearActiveRequest(request.requestId), + Ref.update(requestControlRef, (control) => ({ + ...control, + active: + Option.isSome(control.active) && + control.active.value.requestId === request.requestId + ? Option.none() + : control.active, + })), + ], + { discard: true }, + ), + ), + Effect.catchCause((cause) => + logError("remote update request failed unexpectedly", { + requestId: request.requestId, + cause: String(cause), + }), + ), + ); + + // Sequential by construction: a second remote request queued mid-run is + // handled after the current one, when the state machine resolves it fast. + yield* Stream.runForEach(publisher.updateRequests, handleRequest).pipe(Effect.forkScoped); + + yield* Stream.runForEach(publisher.updateCancellations, (cancellation) => + Effect.gen(function* () { + const activeSignal = yield* Ref.modify(requestControlRef, (control) => { + if ( + Option.isSome(control.active) && + control.active.value.requestId === cancellation.requestId + ) { + return [Option.some(control.active.value.signal), control] as const; + } + return [ + Option.none>(), + { + ...control, + cancelledBeforeStart: [ + ...control.cancelledBeforeStart.filter( + (requestId) => requestId !== cancellation.requestId, + ), + cancellation.requestId, + ], + }, + ] as const; + }); + if (Option.isSome(activeSignal)) { + yield* Deferred.succeed(activeSignal.value, undefined); + } + const matchedPrepared = yield* Ref.modify(preparedUpdateRef, (prepared) => { + if (Option.isNone(prepared) || prepared.value.requestId !== cancellation.requestId) { + return [false, prepared] as const; + } + return [true, prepared.value.status === "prepared" ? Option.none() : prepared] as const; + }); + if (matchedPrepared) { + yield* Ref.update(requestControlRef, (control) => { + return { + ...control, + cancelledBeforeStart: control.cancelledBeforeStart.filter( + (requestId) => requestId !== cancellation.requestId, + ), + }; + }); + } + }), + ).pipe(Effect.forkScoped); + + yield* Stream.runForEach(publisher.updateCommits, (commit) => + Effect.gen(function* () { + const current = yield* updates.getState; + const now = yield* Clock.currentTimeMillis; + const claim = yield* Ref.modify( + preparedUpdateRef, + (prepared): readonly [CommitClaim, Option.Option] => { + if ( + Option.isNone(prepared) || + prepared.value.requestId !== commit.requestId || + (prepared.value.status === "prepared" && prepared.value.expiresAt <= now) + ) { + return [{ _tag: "invalid" as const }, prepared] as const; + } + if (prepared.value.status === "failed") { + return [{ _tag: "failed" as const, prepared: prepared.value }, prepared] as const; + } + if (prepared.value.status === "committing") { + return [{ _tag: "committing" as const }, prepared] as const; + } + return [ + { _tag: "claimed" as const, prepared: prepared.value }, + Option.some({ ...prepared.value, status: "committing" as const }), + ] as const; + }, + ); + if (claim._tag === "invalid") { + yield* publishReport( + yield* updates.getState, + { + outcome: "failed", + reason: "This desktop update is no longer prepared.", + }, + commit.requestId, + ); + return; + } + if (claim._tag === "failed") { + yield* publishReport( + current, + { + outcome: "failed", + reason: claim.prepared.failureReason ?? "The desktop app failed to install the update.", + }, + commit.requestId, + ); + return; + } + if (claim._tag === "committing") { + return; + } + yield* Ref.set(activeRequestIdRef, Option.some(commit.requestId)); + if (current.downloadedVersion !== claim.prepared.downloadedVersion) { + const reason = "This desktop update is no longer prepared."; + if (yield* recordPreparedFailure(commit.requestId, reason)) { + yield* publishReport(current, { outcome: "failed", reason }, commit.requestId); + } + return; + } + const result = yield* updates.installPrepared(claim.prepared.downloadedVersion); + if ( + !result.accepted && + result.state.downloadedVersion === claim.prepared.downloadedVersion && + (yield* updates.isInstallActive) + ) { + // Another install (local, or an earlier remote request) already owns + // the shutdown and will relaunch the app on the same downloaded + // version. This commit joins it: no failure marker, and the client + // proves the handoff the same way, by transport loss then the + // target version on reconnect. + yield* logInfo("remote update commit joining an in-progress install", { + requestId: commit.requestId, + }); + return; + } + if (!result.accepted || result.failed) { + const reason = result.state.message ?? "The desktop app could not start the install."; + if (yield* recordPreparedFailure(commit.requestId, reason)) { + yield* publishReport(result.state, { outcome: "failed", reason }, commit.requestId); + } + return; + } + // A successful install tears down this backend. Do not send a success + // marker from the old process: transport loss followed by the target + // version is the only proof that the handoff succeeded. + }).pipe( + Effect.ensuring(clearActiveRequest(commit.requestId)), + Effect.catchCause((cause) => + logError("remote update commit failed unexpectedly", { + requestId: commit.requestId, + cause: String(cause), + }), + ), + ), + ).pipe(Effect.forkScoped); +}); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index b8c4e185ef43d..1978337df3e72 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,6 +1,4 @@ import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { DesktopUpdateState } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -11,221 +9,14 @@ import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as References from "effect/References"; import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; -import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; - -interface UpdatesHarnessOptions { - readonly checkForUpdates?: Effect.Effect< - void, - ElectronUpdater.ElectronUpdaterCheckForUpdatesError - >; - readonly beforeSetUpdateChannel?: Effect.Effect; - readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; - readonly setDisableDifferentialDownload?: Effect.Effect; - readonly stopBackend?: Effect.Effect; - readonly env?: Record; -} - -const flushCallbacks = Effect.yieldNow; - -function makeHarness(options: UpdatesHarnessOptions = {}) { - let checkCount = 0; - let allowDowngrade = false; - let fullChangelog = false; - const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; - const listeners = new Map void>>(); - const sentStates: DesktopUpdateState[] = []; - - const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { - const eventListeners = listeners.get(eventName) ?? new Set(); - eventListeners.add(listener); - listeners.set(eventName, eventListeners); - }; - - const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { - const eventListeners = listeners.get(eventName); - if (!eventListeners) { - return; - } - eventListeners.delete(listener); - if (eventListeners.size === 0) { - listeners.delete(eventName); - } - }; - - const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { - setFeedURL: (options) => - Effect.sync(() => { - feedUrls.push(options); - }), - setAutoDownload: () => Effect.void, - setAutoInstallOnAppQuit: () => Effect.void, - setChannel: () => Effect.void, - setAllowPrerelease: () => Effect.void, - allowDowngrade: Effect.sync(() => allowDowngrade), - setAllowDowngrade: (value) => - Effect.sync(() => { - allowDowngrade = value; - }), - setFullChangelog: (value) => - Effect.sync(() => { - fullChangelog = value; - }), - setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, - checkForUpdates: Effect.sync(() => { - checkCount += 1; - }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), - downloadUpdate: Effect.void, - quitAndInstall: () => Effect.void, - on: (eventName, listener) => - Effect.acquireRelease( - Effect.sync(() => { - addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); - }), - () => - Effect.sync(() => { - removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); - }), - ).pipe(Effect.asVoid), - } satisfies ElectronUpdater.ElectronUpdater["Service"]); - - const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { - create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.none()), - currentMainOrFirst: Effect.succeed(Option.none()), - focusedMainOrFirst: Effect.succeed(Option.none()), - setMain: () => Effect.void, - clearMain: () => Effect.void, - reveal: () => Effect.void, - sendAll: (_channel, state) => - Effect.sync(() => { - sentStates.push(state as DesktopUpdateState); - }), - destroyAll: Effect.void, - syncAllAppearance: () => Effect.void, - } satisfies ElectronWindow.ElectronWindow["Service"]); - - const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: Effect.succeed("Windows"), - start: Effect.void, - stop: () => options.stopBackend ?? Effect.void, - currentConfig: Effect.succeed(Option.none()), - snapshot: Effect.succeed({ - desiredRunning: false, - ready: false, - activePid: Option.none(), - restartAttempt: 0, - restartScheduled: false, - }), - waitForReady: () => Effect.succeed(true), - }; - const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]); - - const environmentLayer = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ - T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, - T3CODE_DESKTOP_MOCK_UPDATES: "true", - T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", - ...options.env, - }), - ), - ), - ); - - let testSettings: DesktopAppSettings.DesktopSettings = { - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - }; - const setUpdateChannelError = options.setUpdateChannelError; - const settingsLayer = - setUpdateChannelError || options.beforeSetUpdateChannel - ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { - get: Effect.sync(() => testSettings), - load: Effect.sync(() => testSettings), - setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), - setServerExposureMode: () => Effect.die("unexpected server exposure update"), - setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), - setUpdateChannel: (channel) => - setUpdateChannelError - ? Effect.fail(setUpdateChannelError) - : (options.beforeSetUpdateChannel ?? Effect.void).pipe( - Effect.andThen( - Effect.sync(() => { - const changed = testSettings.updateChannel !== channel; - testSettings = { - ...testSettings, - updateChannel: channel, - updateChannelConfiguredByUser: true, - }; - return { settings: testSettings, changed }; - }), - ), - ), - setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), - setWslDistro: () => Effect.die("unexpected WSL distro change"), - setWslOnly: () => Effect.die("unexpected WSL-only toggle"), - applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), - applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), - } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) - : DesktopAppSettings.layer; - - const layer = DesktopUpdates.layer.pipe( - Layer.provideMerge(updaterLayer), - Layer.provideMerge(windowLayer), - Layer.provideMerge(backendLayer), - Layer.provideMerge(DesktopState.layer), - Layer.provideMerge(settingsLayer), - Layer.provideMerge( - DesktopConfig.layerTest({ - T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, - T3CODE_DESKTOP_MOCK_UPDATES: "true", - T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", - ...options.env, - }), - ), - Layer.provideMerge(environmentLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return { - layer, - checkCount: () => checkCount, - feedUrls: () => feedUrls, - fullChangelog: () => fullChangelog, - listenerCount: () => - Array.from(listeners.values()).reduce( - (total, eventListeners) => total + eventListeners.size, - 0, - ), - sentStates, - emit: (eventName: string, payload?: unknown) => { - for (const listener of listeners.get(eventName) ?? []) { - listener(payload); - } - }, - }; -} +import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; describe("DesktopUpdates", () => { it("preserves complete causes for update poller and event failures", () => { @@ -294,6 +85,29 @@ describe("DesktopUpdates", () => { }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("subscribe delivers the latest state plus subsequent changes", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const { latest, changes } = yield* updates.subscribe; + assert.equal(latest.status, "idle"); + + const nextState = yield* Stream.runHead(changes).pipe(Effect.forkChild); + yield* flushCallbacks; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const observed = yield* Fiber.join(nextState); + assert.equal(Option.getOrThrow(observed).status, "available"); + assert.equal(Option.getOrThrow(observed).availableVersion, "1.2.4"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("updates and broadcasts state from updater events", () => { const harness = makeHarness(); @@ -745,6 +559,120 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("keeps windows and restarts backends when quitAndInstall fails", () => { + const harness = makeHarness({ + quitAndInstall: Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("installer refused"), + }), + ), + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("holds the install reservation until failed-install recovery finishes", () => { + const recoveryStarted = Deferred.makeUnsafe(); + const releaseRecovery = Deferred.makeUnsafe(); + const harness = makeHarness({ + quitAndInstall: Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("installer refused"), + }), + ), + startBackend: Deferred.succeed(recoveryStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRecovery)), + ), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const failedInstall = yield* updates.install.pipe(Effect.forkChild); + yield* Deferred.await(recoveryStarted); + assert.isFalse(yield* updates.isInstallActive); + + const overlappingInstall = yield* updates.install; + assert.isFalse(overlappingInstall.accepted); + assert.equal(harness.quitAndInstalls(), 1); + harness.emit("error", new Error("duplicate native installer error")); + yield* flushCallbacks; + assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + + yield* Deferred.succeed(releaseRecovery, undefined); + const failedResult = yield* Fiber.join(failedInstall); + assert.equal(failedResult.state.errorContext, "install"); + + const retry = yield* updates.install; + assert.isTrue(retry.accepted); + assert.equal(harness.quitAndInstalls(), 2); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("recovers when quitAndInstall reports failure through an updater event", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + yield* updates.install; + assert.deepEqual(harness.installSteps, ["quitAndInstall"]); + harness.emit("error", new Error("native installer refused")); + yield* flushCallbacks; + + assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + assert.equal((yield* updates.getState).errorContext, "install"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("rejects a prepared install when the downloaded version changed", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.5" }); + yield* flushCallbacks; + + const result = yield* updates.installPrepared("1.2.4"); + assert.isFalse(result.accepted); + assert.equal(harness.quitAndInstalls(), 0); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("persists channel changes through the settings service", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index a3d7da17b126a..20f005f2ab2db 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -11,12 +11,16 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -44,8 +48,13 @@ import { const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; +const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90); -type UpdateAction = "check" | "download" | "install" | "channel"; +type UpdateAction = "check" | "download" | "install" | "install-recovery" | "channel"; + +interface DesktopPreparedUpdateInstallResult extends DesktopUpdateActionResult { + readonly failed: boolean; +} const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; @@ -152,6 +161,20 @@ export class DesktopUpdates extends Context.Service< DesktopUpdates, { readonly getState: Effect.Effect; + /** True while a check, download, install, or channel change holds the + updater's single action reservation. */ + readonly isActionActive: Effect.Effect; + /** True only while an install owns the updater action reservation. */ + readonly isInstallActive: Effect.Effect; + /** Current state plus a stream of every later state change. */ + readonly subscribe: Effect.Effect< + { + readonly latest: DesktopUpdateState; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; readonly configure: Effect.Effect; @@ -161,6 +184,9 @@ export class DesktopUpdates extends Context.Service< readonly check: (reason: string) => Effect.Effect; readonly download: Effect.Effect; readonly install: Effect.Effect; + readonly installPrepared: ( + expectedVersion: string, + ) => Effect.Effect; } >()("@t3tools/desktop/updates/DesktopUpdates") {} @@ -258,6 +284,7 @@ export const make = Effect.gen(function* () { const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); const activeUpdateActionRef = yield* Ref.make>(Option.none()); + const finishedUpdateActions = yield* PubSub.unbounded(); const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); const updateStateRef = yield* Ref.make( @@ -268,12 +295,21 @@ export const make = Effect.gen(function* () { ), ); + const stateChanges = yield* PubSub.sliding(16); + // Makes ref writes + publishes atomic against subscribe, so a snapshot + // never overlaps with the first change a subscriber receives. + const stateMutex = yield* Semaphore.make(1); + const emitState = Ref.get(updateStateRef).pipe( Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)), ); const setState = (state: DesktopUpdateState): Effect.Effect => - Ref.set(updateStateRef, state).pipe(Effect.andThen(emitState)); + stateMutex + .withPermits(1)( + Ref.set(updateStateRef, state).pipe(Effect.andThen(PubSub.publish(stateChanges, state))), + ) + .pipe(Effect.andThen(emitState)); const updateState = ( f: (state: DesktopUpdateState) => DesktopUpdateState, @@ -327,8 +363,13 @@ export const make = Effect.gen(function* () { ); const finishUpdateAction = (action: UpdateAction): Effect.Effect => - Ref.update(activeUpdateActionRef, (activeAction) => - Option.isSome(activeAction) && activeAction.value === action ? Option.none() : activeAction, + Ref.modify(activeUpdateActionRef, (activeAction) => { + const finished = Option.isSome(activeAction) && activeAction.value === action; + return [finished, finished ? Option.none() : activeAction] as const; + }).pipe( + Effect.flatMap((finished) => + finished ? PubSub.publish(finishedUpdateActions, action).pipe(Effect.asVoid) : Effect.void, + ), ); const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* ( @@ -396,7 +437,10 @@ export const make = Effect.gen(function* () { return yield* actionReservation === "held" ? check - : check.pipe(Effect.ensuring(finishUpdateAction("check"))); + : check.pipe( + Effect.onInterrupt(() => setState(state)), + Effect.ensuring(finishUpdateAction("check")), + ); }); const downloadAvailableUpdate = Effect.gen(function* () { @@ -462,85 +506,156 @@ export const make = Effect.gen(function* () { { discard: true }, ); - const installDownloadedUpdate = Effect.gen(function* () { - const state = yield* Ref.get(updateStateRef); - const hasInstallableDownload = - state.downloadedVersion !== null && - (state.status === "downloaded" || - (state.status === "error" && - (state.errorContext === null || state.errorContext === "install"))); - if ( - (yield* Ref.get(desktopState.quitting)) || - !(yield* Ref.get(updaterConfiguredRef)) || - !hasInstallableDownload - ) { - return { accepted: false, completed: false }; - } - - if (!(yield* tryStartUpdateAction("install"))) { - return { accepted: false, completed: false }; - } - - yield* Ref.set(desktopState.quitting, true); + const recoverFailedInstall = Effect.fn("desktop.updates.recoverFailedInstall")(function* ( + message: string, + ) { + const ownsRecovery = yield* Ref.modify(activeUpdateActionRef, (activeAction) => + Option.isSome(activeAction) && activeAction.value === "install" + ? ([true, Option.some("install-recovery")] as const) + : ([false, activeAction] as const), + ); + if (!ownsRecovery) return; - return yield* Effect.gen(function* () { - // Stop every backend in the pool, not just the primary. With - // parallel WSL + Windows backends, leaving the WSL instance up - // means quitAndInstall's app.quit() exits before the pool's - // scope cascade has a chance to run its stop finalizer, so the - // WSL child gets hard-killed by the OS instead of receiving - // SIGTERM + grace. Stops run concurrently with the same 5s - // budget the primary had on its own. + yield* Ref.set(desktopState.quitting, false); + yield* Effect.gen(function* () { const instances = yield* pool.list; - yield* Effect.forEach( - instances, - (instance) => instance.stop({ timeout: Duration.seconds(5) }), - { concurrency: "unbounded" }, - ); - yield* electronWindow.destroyAll; - yield* electronUpdater.quitAndInstall({ - isSilent: true, - isForceRunAfter: true, - }); - return { accepted: true, completed: false }; + const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, { + concurrency: "unbounded", + discard: true, + }).pipe(Effect.exit); + yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); + if (Exit.isFailure(restartExit)) { + yield* logUpdaterError("Desktop update install recovery could not restart every backend."); + } }).pipe( - Effect.catchTags({ - ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( - function* (error) { - yield* resetInstallAction; - yield* updateState((current) => - reduceDesktopUpdateStateOnInstallFailure(current, error.message), + Effect.catchCause(() => + logUpdaterError("Desktop update install recovery failed unexpectedly."), + ), + Effect.ensuring(finishUpdateAction("install-recovery")), + ); + }); + + const installDownloadedUpdate = (expectedVersion?: string) => + Effect.scoped( + Effect.gen(function* () { + const actionCompletions = yield* PubSub.subscribe(finishedUpdateActions); + let admission: "admitted" | "refused" | "wait-for-check" = "wait-for-check"; + while (admission === "wait-for-check") { + admission = yield* stateMutex.withPermits(1)( + Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + const activeAction = yield* Ref.get(activeUpdateActionRef); + const hasExpectedDownload = + state.downloadedVersion !== null && + (expectedVersion === undefined || state.downloadedVersion === expectedVersion); + if ( + (yield* Ref.get(desktopState.quitting)) || + !(yield* Ref.get(updaterConfiguredRef)) || + !hasExpectedDownload + ) { + return "refused" as const; + } + if (Option.isSome(activeAction)) { + return activeAction.value === "check" && expectedVersion !== undefined + ? ("wait-for-check" as const) + : ("refused" as const); + } + const hasInstallableDownload = + state.status === "downloaded" || + (state.status === "error" && + (state.errorContext === null || state.errorContext === "install")); + if (!hasInstallableDownload) return "refused" as const; + return (yield* tryStartUpdateAction("install")) + ? ("admitted" as const) + : ("refused" as const); + }), + ); + if (admission === "wait-for-check") { + const finishedAction = yield* PubSub.take(actionCompletions).pipe( + Effect.timeoutOption(PREPARED_INSTALL_CHECK_WAIT), ); - yield* logUpdaterError(error.message, { - errorTag: error._tag, - channel: error.channel, - isSilent: error.isSilent, - isForceRunAfter: error.isForceRunAfter, - }); - return { accepted: true, completed: false }; - }, - ), - }), - Effect.onInterrupt(() => resetInstallAction), - Effect.catchCause((cause) => - Effect.gen(function* () { - if (Cause.hasInterruptsOnly(cause)) { - return yield* Effect.failCause(cause); + if (Option.isNone(finishedAction)) { + admission = "refused"; + } } - yield* resetInstallAction; - const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause }); - yield* updateState((current) => - reduceDesktopUpdateStateOnInstallFailure(current, error.message), + } + if (admission === "refused") { + return { accepted: false, completed: false, failed: false }; + } + + yield* Ref.set(desktopState.quitting, true); + + return yield* Effect.gen(function* () { + // Stop every backend in the pool, not just the primary. With + // parallel WSL + Windows backends, leaving the WSL instance up + // means quitAndInstall's app.quit() exits before the pool's + // scope cascade has a chance to run its stop finalizer, so the + // WSL child gets hard-killed by the OS instead of receiving + // SIGTERM + grace. Stops run concurrently with the same 5s + // budget the primary had on its own. + const instances = yield* pool.list; + yield* Effect.forEach( + instances, + (instance) => instance.stop({ timeout: Duration.seconds(5) }), + { concurrency: "unbounded" }, ); - yield* logUpdaterError(error.message, { - errorTag: error._tag, - action: error.action, + yield* electronUpdater.quitAndInstall({ + isSilent: true, + isForceRunAfter: true, }); - return { accepted: true, completed: false }; - }), - ), - ); - }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); + return { accepted: true, completed: false, failed: false }; + }).pipe( + Effect.catchTags({ + ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( + function* (error) { + yield* recoverFailedInstall(error.message); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + isSilent: error.isSilent, + isForceRunAfter: error.isForceRunAfter, + }); + return { accepted: true, completed: false, failed: true }; + }, + ), + }), + Effect.onInterrupt(() => resetInstallAction), + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } + const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause }); + yield* recoverFailedInstall(error.message); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + action: error.action, + }); + return { accepted: true, completed: false, failed: true }; + }), + ), + ); + }), + ).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); + + const installWithExpectedVersion = (expectedVersion?: string) => + Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) { + return { + accepted: false, + completed: false, + failed: false, + state: yield* Ref.get(updateStateRef), + }; + } + const result = yield* installDownloadedUpdate(expectedVersion); + return { + accepted: result.accepted, + completed: result.completed, + failed: result.failed, + state: yield* Ref.get(updateStateRef), + }; + }).pipe(Effect.withSpan("desktop.updates.install")); const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( @@ -640,15 +755,14 @@ export const make = Effect.gen(function* () { ) { const activeAction = yield* activeUpdateAction; const error = new DesktopUpdaterReportedError({ - operation: Option.getOrElse(activeAction, () => "background" as const), + operation: Option.match(activeAction, { + onNone: () => "background" as const, + onSome: (action) => (action === "install-recovery" ? "install" : action), + }), cause, }); if (Option.isSome(activeAction) && activeAction.value === "install") { - yield* finishUpdateAction("install"); - yield* Ref.set(desktopState.quitting, false); - yield* updateState((current) => - reduceDesktopUpdateStateOnInstallFailure(current, error.message), - ); + yield* recoverFailedInstall(error.message); yield* logUpdaterError(error.message, { errorTag: error._tag, operation: error.operation, @@ -733,6 +847,17 @@ export const make = Effect.gen(function* () { return DesktopUpdates.of({ getState: Ref.get(updateStateRef), + isActionActive: activeUpdateAction.pipe(Effect.map(Option.isSome)), + isInstallActive: activeUpdateAction.pipe( + Effect.map((action) => Option.isSome(action) && action.value === "install"), + ), + subscribe: stateMutex.withPermits(1)( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(stateChanges); + const latest = yield* Ref.get(updateStateRef); + return { latest, changes: Stream.fromSubscription(subscription) }; + }), + ), emitState, disabledReason: resolveDisabledReason, configure: Effect.gen(function* () { @@ -804,7 +929,7 @@ export const make = Effect.gen(function* () { const activeAction = yield* tryStartChannelChange; if (Option.isSome(activeAction)) { return yield* new DesktopUpdateActionInProgressError({ - action: activeAction.value, + action: activeAction.value === "install-recovery" ? "install" : activeAction.value, requestedChannel: nextChannel, }); } @@ -861,21 +986,10 @@ export const make = Effect.gen(function* () { state: yield* Ref.get(updateStateRef), }; }).pipe(Effect.withSpan("desktop.updates.download")), - install: Effect.gen(function* () { - if (yield* Ref.get(desktopState.quitting)) { - return { - accepted: false, - completed: false, - state: yield* Ref.get(updateStateRef), - }; - } - const result = yield* installDownloadedUpdate; - return { - accepted: result.accepted, - completed: result.completed, - state: yield* Ref.get(updateStateRef), - }; - }).pipe(Effect.withSpan("desktop.updates.install")), + install: installWithExpectedVersion().pipe( + Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })), + ), + installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion), }); }); diff --git a/apps/desktop/src/updates/remoteUpdateFlow.test.ts b/apps/desktop/src/updates/remoteUpdateFlow.test.ts new file mode 100644 index 0000000000000..7fd50a82f4998 --- /dev/null +++ b/apps/desktop/src/updates/remoteUpdateFlow.test.ts @@ -0,0 +1,218 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { DesktopUpdateState } from "@t3tools/contracts"; + +import { + MAX_REMOTE_UPDATE_CHECKS, + MAX_REMOTE_UPDATE_DOWNLOADS, + nextRemoteDesktopUpdateStep, + normalizeRemoteUpdateReason, + type RemoteDesktopUpdateAttempts, +} from "./remoteUpdateFlow.ts"; + +const NO_ATTEMPTS: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; + +function makeState(overrides: Partial = {}): DesktopUpdateState { + return { + enabled: true, + status: "idle", + channel: "latest", + currentVersion: "1.2.3", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + releaseNotes: [], + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + omittedReleaseCount: 0, + ...overrides, + }; +} + +describe("nextRemoteDesktopUpdateStep", () => { + it("fails immediately when updates are disabled, preferring the known reason", () => { + const state = makeState({ enabled: false, status: "disabled" }); + assert.deepEqual(nextRemoteDesktopUpdateStep(state, NO_ATTEMPTS, "dev build"), { + action: "done", + outcome: "failed", + reason: "dev build", + }); + assert.deepEqual(nextRemoteDesktopUpdateStep(state, NO_ATTEMPTS, null), { + action: "done", + outcome: "failed", + reason: "Automatic updates are disabled on this machine.", + }); + }); + + it("installs only from the downloaded status the updater will accept", () => { + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ status: "downloaded", downloadedVersion: "1.2.4" }), + NO_ATTEMPTS, + null, + ), + { action: "install" }, + ); + // A previous install failure keeps status "downloaded", so a remote run + // retries the install. + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ + status: "downloaded", + downloadedVersion: "1.2.4", + errorContext: "install", + message: "quitAndInstall failed", + }), + NO_ATTEMPTS, + null, + ), + { action: "install" }, + ); + // A download survives an unrelated background updater error, so a run + // installs it instead of replaying that error. + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ + status: "error", + downloadedVersion: "1.2.4", + errorContext: null, + message: "background updater error", + }), + NO_ATTEMPTS, + null, + ), + { action: "install" }, + ); + // A leftover download behind a check error is not installable: fresh + // runs re-check, and post-check the error is terminal. + const staleError = makeState({ + status: "error", + downloadedVersion: "1.2.4", + errorContext: "check", + message: "feed unreachable", + }); + assert.deepEqual(nextRemoteDesktopUpdateStep(staleError, NO_ATTEMPTS, null), { + action: "check", + }); + assert.deepEqual(nextRemoteDesktopUpdateStep(staleError, { checks: 1, downloads: 0 }, null), { + action: "done", + outcome: "failed", + reason: "feed unreachable", + }); + }); + + it("rides along while a check or download is already in flight", () => { + assert.deepEqual( + nextRemoteDesktopUpdateStep(makeState({ status: "checking" }), NO_ATTEMPTS, null), + { + action: "wait", + }, + ); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ status: "downloading", availableVersion: "1.2.4", downloadPercent: 40 }), + NO_ATTEMPTS, + null, + ), + { action: "wait" }, + ); + }); + + it("downloads an available update until the attempt cap, then fails", () => { + const available = makeState({ status: "available", availableVersion: "1.2.4" }); + assert.deepEqual(nextRemoteDesktopUpdateStep(available, NO_ATTEMPTS, null), { + action: "download", + }); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + available, + { checks: 1, downloads: MAX_REMOTE_UPDATE_DOWNLOADS }, + null, + ), + { + action: "done", + outcome: "failed", + reason: "The desktop app failed to download the update.", + }, + ); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ + status: "available", + availableVersion: "1.2.4", + message: "network blipped", + }), + { checks: 1, downloads: MAX_REMOTE_UPDATE_DOWNLOADS }, + null, + ), + { action: "done", outcome: "failed", reason: "network blipped" }, + ); + }); + + it("re-checks stale up-to-date and error states before trusting them", () => { + // These states are retained from earlier/background checks; a remote + // request must look again instead of replaying them. + assert.deepEqual( + nextRemoteDesktopUpdateStep(makeState({ status: "up-to-date" }), NO_ATTEMPTS, null), + { action: "check" }, + ); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ status: "error", message: "feed unreachable" }), + NO_ATTEMPTS, + null, + ), + { action: "check" }, + ); + }); + + it("reports up-to-date and error states as terminal after this run's check", () => { + const checked = { checks: 1, downloads: 0 }; + assert.deepEqual( + nextRemoteDesktopUpdateStep(makeState({ status: "up-to-date" }), checked, null), + { action: "done", outcome: "up-to-date" }, + ); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ status: "error", message: "feed unreachable" }), + checked, + null, + ), + { action: "done", outcome: "failed", reason: "feed unreachable" }, + ); + assert.deepEqual(nextRemoteDesktopUpdateStep(makeState({ status: "error" }), checked, null), { + action: "done", + outcome: "failed", + reason: "The desktop app update failed.", + }); + }); + + it("checks from idle until the attempt cap, then fails", () => { + assert.deepEqual(nextRemoteDesktopUpdateStep(makeState(), NO_ATTEMPTS, null), { + action: "check", + }); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState(), + { checks: MAX_REMOTE_UPDATE_CHECKS, downloads: 0 }, + null, + ), + { + action: "done", + outcome: "failed", + reason: "The desktop app did not report an update result.", + }, + ); + }); + + it("drops blank reasons so the wire report still encodes", () => { + assert.equal(normalizeRemoteUpdateReason(undefined), undefined); + assert.equal(normalizeRemoteUpdateReason(""), undefined); + assert.equal(normalizeRemoteUpdateReason(" "), undefined); + assert.equal(normalizeRemoteUpdateReason(" feed unreachable "), "feed unreachable"); + }); +}); diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts new file mode 100644 index 0000000000000..50b077313330e --- /dev/null +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -0,0 +1,115 @@ +import type { DesktopUpdateRemoteOutcome, DesktopUpdateState } from "@t3tools/contracts"; + +/** + * What a server-triggered update run should do next, given the updater's + * current state. "wait" means an action (possibly started locally) is in + * flight and the run should ride along until the next state change. + */ +export type RemoteDesktopUpdateStep = + | { readonly action: "check" } + | { readonly action: "download" } + | { readonly action: "install" } + | { readonly action: "wait" } + | { + readonly action: "done"; + readonly outcome: DesktopUpdateRemoteOutcome; + readonly reason?: string; + }; + +/** + * How many times this run already issued each action. The caps are what stop + * a check -> up-to-date -> check loop and endless download retries; they are + * counts rather than booleans because a state event raced by the local + * 4-minute poller can re-show an already-handled status once. + */ +export interface RemoteDesktopUpdateAttempts { + readonly checks: number; + readonly downloads: number; +} + +export const MAX_REMOTE_UPDATE_CHECKS = 2; +export const MAX_REMOTE_UPDATE_DOWNLOADS = 3; + +/** Same predicate DesktopUpdates.installDownloadedUpdate uses for admission. */ +export function isInstallableDesktopUpdateState(state: DesktopUpdateState): boolean { + return ( + state.downloadedVersion !== null && + (state.status === "downloaded" || + (state.status === "error" && + (state.errorContext === null || state.errorContext === "install"))) + ); +} + +export function nextRemoteDesktopUpdateStep( + state: DesktopUpdateState, + attempts: RemoteDesktopUpdateAttempts, + disabledReason: string | null, +): RemoteDesktopUpdateStep { + if (!state.enabled || state.status === "disabled") { + return { + action: "done", + outcome: "failed", + reason: disabledReason ?? "Automatic updates are disabled on this machine.", + }; + } + // Mirror installDownloadedUpdate's own admission rule exactly, so the run + // never reports an install that the updater then refuses. A download + // survives an unrelated background updater error (errorContext null) and + // a previous failed install (errorContext "install"); it does not survive + // a check or download error, which fall through to the error branch. + if (isInstallableDesktopUpdateState(state)) { + return { action: "install" }; + } + if (state.status === "downloading" || state.status === "checking") { + return { action: "wait" }; + } + if (state.status === "available") { + if (attempts.downloads >= MAX_REMOTE_UPDATE_DOWNLOADS) { + return { + action: "done", + outcome: "failed", + reason: state.message ?? "The desktop app failed to download the update.", + }; + } + return { action: "download" }; + } + // "up-to-date" and "error" are retained from earlier/background checks, + // so before this run has issued its own check they are stale, not + // terminal: the whole point of a remote request is to look again. + if (state.status === "up-to-date") { + if (attempts.checks === 0) { + return { action: "check" }; + } + return { action: "done", outcome: "up-to-date" }; + } + if (state.status === "error") { + if (attempts.checks === 0) { + return { action: "check" }; + } + return { + action: "done", + outcome: "failed", + reason: state.message ?? "The desktop app update failed.", + }; + } + // status === "idle" + if (attempts.checks >= MAX_REMOTE_UPDATE_CHECKS) { + return { + action: "done", + outcome: "failed", + reason: "The desktop app did not report an update result.", + }; + } + return { action: "check" }; +} + +/** + * Normalizes an updater message for the `reason` wire field, which is a + * TrimmedNonEmptyString. Updater messages are plain strings and may be blank; + * a blank reason would fail encoding and silently drop the terminal report, + * leaving the server to wait for its timeout. + */ +export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined { + const trimmed = reason?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts new file mode 100644 index 0000000000000..53a6dc97f5a40 --- /dev/null +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -0,0 +1,242 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; + +/** Shared DesktopUpdates test harness: a fully stubbed updater layer whose + electron-updater events are driven by hand via `emit`. Used by + DesktopUpdates.test.ts and DesktopRemoteUpdates.test.ts. */ + +export const flushCallbacks = Effect.yieldNow; + +export interface UpdatesHarnessOptions { + readonly checkForUpdates?: Effect.Effect< + void, + ElectronUpdater.ElectronUpdaterCheckForUpdatesError + >; + readonly beforeSetUpdateChannel?: Effect.Effect; + readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; + readonly setDisableDifferentialDownload?: Effect.Effect; + readonly downloadUpdate?: Effect.Effect; + readonly quitAndInstall?: Effect.Effect; + readonly stopBackend?: Effect.Effect; + readonly startBackend?: Effect.Effect; + readonly env?: Record; +} + +export function makeHarness(options: UpdatesHarnessOptions = {}) { + let checkCount = 0; + let quitAndInstallCount = 0; + let downloadCount = 0; + let allowDowngrade = false; + let fullChangelog = false; + const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; + const listeners = new Map void>>(); + const sentStates: DesktopUpdateState[] = []; + const installSteps: string[] = []; + + const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName) ?? new Set(); + eventListeners.add(listener); + listeners.set(eventName, eventListeners); + }; + + const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName); + if (!eventListeners) { + return; + } + eventListeners.delete(listener); + if (eventListeners.size === 0) { + listeners.delete(eventName); + } + }; + + const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { + setFeedURL: (options) => + Effect.sync(() => { + feedUrls.push(options); + }), + setAutoDownload: () => Effect.void, + setAutoInstallOnAppQuit: () => Effect.void, + setChannel: () => Effect.void, + setAllowPrerelease: () => Effect.void, + allowDowngrade: Effect.sync(() => allowDowngrade), + setAllowDowngrade: (value) => + Effect.sync(() => { + allowDowngrade = value; + }), + setFullChangelog: (value) => + Effect.sync(() => { + fullChangelog = value; + }), + setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, + checkForUpdates: Effect.sync(() => { + checkCount += 1; + }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), + downloadUpdate: Effect.sync(() => { + downloadCount += 1; + }).pipe(Effect.andThen(options.downloadUpdate ?? Effect.void)), + quitAndInstall: () => + Effect.sync(() => { + quitAndInstallCount += 1; + installSteps.push("quitAndInstall"); + }).pipe(Effect.andThen(options.quitAndInstall ?? Effect.void)), + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); + }), + () => + Effect.sync(() => { + removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronUpdater.ElectronUpdater["Service"]); + + const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Effect.die("unexpected BrowserWindow creation"), + main: Effect.succeed(Option.none()), + currentMainOrFirst: Effect.succeed(Option.none()), + focusedMainOrFirst: Effect.succeed(Option.none()), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: (_channel, state) => + Effect.sync(() => { + sentStates.push(state as DesktopUpdateState); + }), + destroyAll: Effect.sync(() => { + installSteps.push("destroyAll"); + }), + syncAllAppearance: () => Effect.void, + } satisfies ElectronWindow.ElectronWindow["Service"]); + + const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: Effect.succeed("Windows"), + start: Effect.sync(() => { + installSteps.push("startBackend"); + }).pipe(Effect.andThen(options.startBackend ?? Effect.void)), + stop: () => options.stopBackend ?? Effect.void, + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed({ + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + }), + waitForReady: () => Effect.succeed(true), + }; + const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]); + + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + ...options.env, + }), + ), + ), + ); + + let testSettings: DesktopAppSettings.DesktopSettings = { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + }; + const setUpdateChannelError = options.setUpdateChannelError; + const settingsLayer = + setUpdateChannelError || options.beforeSetUpdateChannel + ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.sync(() => testSettings), + load: Effect.sync(() => testSettings), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: (channel) => + setUpdateChannelError + ? Effect.fail(setUpdateChannelError) + : (options.beforeSetUpdateChannel ?? Effect.void).pipe( + Effect.andThen( + Effect.sync(() => { + const changed = testSettings.updateChannel !== channel; + testSettings = { + ...testSettings, + updateChannel: channel, + updateChannelConfiguredByUser: true, + }; + return { settings: testSettings, changed }; + }), + ), + ), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) + : DesktopAppSettings.layer; + + const layer = DesktopUpdates.layer.pipe( + Layer.provideMerge(updaterLayer), + Layer.provideMerge(windowLayer), + Layer.provideMerge(backendLayer), + Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(settingsLayer), + Layer.provideMerge( + DesktopConfig.layerTest({ + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + ...options.env, + }), + ), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return { + layer, + checkCount: () => checkCount, + quitAndInstalls: () => quitAndInstallCount, + installSteps, + downloadCount: () => downloadCount, + feedUrls: () => feedUrls, + fullChangelog: () => fullChangelog, + listenerCount: () => + Array.from(listeners.values()).reduce( + (total, eventListeners) => total + eventListeners.size, + 0, + ), + sentStates, + emit: (eventName: string, payload?: unknown) => { + for (const listener of listeners.get(eventName) ?? []) { + listener(payload); + } + }, + }; +} diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 595b0dd113d34..6f10d69d7a317 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -60,6 +60,9 @@ const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { getState: Effect.die("unexpected getState"), + isActionActive: Effect.succeed(false), + isInstallActive: Effect.succeed(false), + subscribe: Effect.die("unexpected subscribe"), emitState: Effect.void, disabledReason: Effect.succeed(Option.none()), configure: Effect.void, @@ -67,6 +70,7 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { check: () => Effect.die("unexpected check"), download: Effect.die("unexpected download"), install: Effect.die("unexpected install"), + installPrepared: () => Effect.die("unexpected installPrepared"), } satisfies DesktopUpdates.DesktopUpdates["Service"]); const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec992..f9d5a302ee9e3 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -35,6 +35,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, + [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 71880ec4ecfd9..d7c383140e4d0 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -9,6 +9,7 @@ import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; +import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; @@ -19,6 +20,7 @@ interface HarnessOptions { readonly managed?: boolean; readonly preflight?: "ready" | "blocked"; readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; + readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; } const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( @@ -88,6 +90,13 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( const selfUpdate = yield* ServerSelfUpdate.make().pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher), + Effect.provideService( + DesktopAppUpdate.DesktopAppUpdate, + options.desktopAppUpdate ?? { + available: false, + run: () => Effect.die("unexpected desktop app update run"), + }, + ), Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"), Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), ); @@ -121,6 +130,31 @@ it.layer(NodeServices.layer)("server self update", (it) => { }), ); + it.effect("delegates desktop-managed updates to the desktop app when available", () => + Effect.gen(function* () { + const stages: string[] = []; + const { selfUpdate, order } = yield* makeHarness({ + mode: "desktop", + desktopAppUpdate: { + available: true, + run: (reportProgress) => + reportProgress("downloading").pipe( + Effect.andThen(reportProgress("installing")), + Effect.as({ targetVersion: "1.2.0", method: "desktop-app" as const }), + ), + commit: () => Effect.never, + }, + }); + const result = yield* selfUpdate.update({ targetVersion: "1.1.0" }, (stage) => + Effect.sync(() => void stages.push(stage)), + ); + expect(result).toEqual({ targetVersion: "1.2.0", method: "desktop-app" }); + expect(stages).toEqual(["downloading", "installing"]); + // The launcher staging path must not run on the desktop path. + expect(order).toEqual([]); + }), + ); + it.effect("preserves the preflight refusal reason", () => Effect.gen(function* () { const { selfUpdate } = yield* makeHarness({ preflight: "blocked" }); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 015fd557d3bd7..1c4f9aa751090 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -15,6 +15,7 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as ServerConfig from "../config.ts"; +import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, @@ -42,11 +43,15 @@ export class ServerSelfUpdate extends Context.Service< input: ServerSelfUpdateInput, reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, ) => Effect.Effect; + readonly commitDesktopUpdate: ( + requestId: string, + ) => Effect.Effect; } >()("t3/cloud/selfUpdate/ServerSelfUpdate") {} export const make = Effect.fn("cloud.server_self_update.make")(function* () { const serverConfig = yield* ServerConfig.ServerConfig; + const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate; const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; const runner = yield* ProcessRunner.ProcessRunner; const fs = yield* FileSystem.FileSystem; @@ -65,6 +70,12 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { "cloud.server_self_update.update", )(function* (input, reportProgress = () => Effect.void) { if (capability === "desktop-managed") { + // input.targetVersion is meaningless here: the desktop app's own + // update feed decides what it downloads, and the result carries what + // it actually got. + if (desktopAppUpdate.available) { + return yield* desktopAppUpdate.run(reportProgress); + } return yield* failWith( "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", ); @@ -191,7 +202,10 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { }).pipe(Effect.onError(() => Ref.set(inFlight, false))); }); - return ServerSelfUpdate.of({ update }); + return ServerSelfUpdate.of({ + update, + commitDesktopUpdate: (requestId) => desktopAppUpdate.commit(requestId), + }); }); export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts new file mode 100644 index 0000000000000..ea89f45e6dd8d --- /dev/null +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts @@ -0,0 +1,247 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import type { DesktopUpdateState, DesktopUpdateStatusReport } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; +import * as DesktopAppUpdate from "./DesktopAppUpdate.ts"; + +function makeState(overrides: Partial = {}): DesktopUpdateState { + return { + enabled: true, + status: "idle", + channel: "latest", + currentVersion: "1.2.3", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + releaseNotes: [], + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + omittedReleaseCount: 0, + ...overrides, + }; +} + +function report( + requestId: string, + state: DesktopUpdateState, + terminal?: { + readonly outcome: DesktopUpdateStatusReport["outcome"]; + readonly reason?: string; + }, +): DesktopUpdateStatusReport { + return { + version: 1, + type: "desktopUpdateStatus", + requestId, + ...(terminal?.outcome === undefined ? {} : { outcome: terminal.outcome }), + ...(terminal?.reason === undefined ? {} : { reason: terminal.reason }), + state, + }; +} + +interface HarnessOptions { + readonly mode?: "web" | "desktop"; + readonly controlFd?: number | undefined; + /** Reports emitted for the run, given the requestId the service generated. + The stream ends after the last one unless `keepOpen` is set. */ + readonly reports?: (requestId: string) => readonly DesktopUpdateStatusReport[]; + readonly keepOpen?: boolean; +} + +const makeHarness = Effect.fn("test.make_desktop_app_update_harness")(function* ( + options: HarnessOptions = {}, +) { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-desktop-app-update-test-" }); + const requestIdDeferred = yield* Deferred.make(); + const baseConfig = yield* ServerConfig.ServerConfig.pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + const config: ServerConfig.ServerConfig["Service"] = { + ...baseConfig, + mode: options.mode ?? "desktop", + ...("controlFd" in options + ? { desktopTelemetryControlFd: options.controlFd } + : { desktopTelemetryControlFd: 5 }), + }; + const reportsForRun = options.reports ?? (() => []); + const changes = Stream.unwrap( + Deferred.await(requestIdDeferred).pipe( + Effect.map((requestId) => { + const emitted = Stream.fromIterable(reportsForRun(requestId)); + return options.keepOpen ? Stream.concat(emitted, Stream.never) : emitted; + }), + ), + ); + + const service = yield* DesktopAppUpdate.make().pipe( + Effect.provide( + Layer.mergeAll( + DesktopTelemetryReceiver.layerTest({ + requestDesktopUpdate: (requestId) => + Deferred.succeed(requestIdDeferred, requestId).pipe(Effect.asVoid), + desktopUpdates: Effect.succeed({ + latest: Option.none(), + changes, + }), + }), + ServerConfig.layer(config), + ), + ), + ); + return { service }; +}); + +it.layer(NodeServices.layer)("desktop app update", (it) => { + it.effect("is unavailable without desktop mode or the control fd", () => + Effect.gen(function* () { + const web = yield* makeHarness({ mode: "web" }); + expect(web.service.available).toBe(false); + const noFd = yield* makeHarness({ controlFd: undefined }); + expect(noFd.service.available).toBe(false); + expect((yield* noFd.service.run(() => Effect.void).pipe(Effect.flip)).reason).toContain( + "not started by the T3 Code desktop app", + ); + const desktop = yield* makeHarness(); + expect(desktop.service.available).toBe(true); + }), + ); + + it.effect("collapses state reports into progress stages and succeeds on installing", () => + Effect.gen(function* () { + const { service } = yield* makeHarness({ + reports: (requestId) => [ + report(requestId, makeState({ status: "checking" })), + report(requestId, makeState({ status: "available", availableVersion: "1.2.4" })), + report(requestId, makeState({ status: "downloading", downloadPercent: 40 })), + // Reports from another run must be ignored. + report("other-run", makeState({ status: "error", message: "unrelated" })), + report(requestId, makeState({ status: "downloaded", downloadedVersion: "1.2.4" }), { + outcome: "ready-to-install", + }), + ], + }); + const stages: string[] = []; + const result = yield* service.run((stage) => Effect.sync(() => void stages.push(stage))); + expect(result).toEqual({ + targetVersion: "1.2.4", + method: "desktop-app", + desktopUpdateToken: expect.any(String), + }); + // "downloading" is not repeated for every download report. + expect(stages).toEqual(["downloading", "installing"]); + + // Success releases the in-flight guard: if the desktop rejected the + // install after reporting, the server must accept a retry instead of + // refusing until restart. (The second run fails differently because + // the stub report stream is exhausted.) + const retry = yield* service.run(() => Effect.void).pipe(Effect.flip); + expect(retry.reason).not.toBe("A desktop app update is already in progress."); + }), + ); + + it.effect("maps up-to-date and failed outcomes to readable errors", () => + Effect.gen(function* () { + const upToDate = yield* makeHarness({ + reports: (requestId) => [ + report(requestId, makeState({ status: "up-to-date" }), { outcome: "up-to-date" }), + ], + }); + expect((yield* upToDate.service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( + "The T3 Code desktop app on this machine is already up to date on 1.2.3.", + ); + + const failed = yield* makeHarness({ + reports: (requestId) => [ + report(requestId, makeState({ status: "error", message: "feed unreachable" }), { + outcome: "failed", + reason: "feed unreachable", + }), + ], + }); + expect((yield* failed.service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( + "feed unreachable", + ); + }), + ); + + it.effect("replays a retained commit failure for the preparation token", () => + Effect.gen(function* () { + const { service } = yield* makeHarness({ + reports: (requestId) => [ + report(requestId, makeState({ status: "downloaded", downloadedVersion: "1.2.4" }), { + outcome: "ready-to-install", + }), + report( + requestId, + makeState({ + status: "downloaded", + downloadedVersion: "1.2.4", + errorContext: "install", + message: "installer refused", + }), + { outcome: "failed", reason: "installer refused" }, + ), + ], + }); + const prepared = yield* service.run(() => Effect.void); + + expect( + (yield* service.commit(prepared.desktopUpdateToken ?? "missing").pipe(Effect.flip)).reason, + ).toBe("installer refused"); + }), + ); + + it.effect("fails when the desktop stops reporting before a terminal outcome", () => + Effect.gen(function* () { + const { service } = yield* makeHarness({ + reports: (requestId) => [report(requestId, makeState({ status: "checking" }))], + }); + expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( + "The desktop app stopped reporting its update.", + ); + }), + ); + + it.effect("allows only one desktop update at a time", () => + Effect.gen(function* () { + const { service } = yield* makeHarness({ reports: () => [], keepOpen: true }); + const first = yield* Effect.forkChild( + service.run(() => Effect.void), + { + startImmediately: true, + }, + ); + expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( + "A desktop app update is already in progress.", + ); + yield* Fiber.interrupt(first); + + const retry = yield* Effect.forkChild( + service.run(() => Effect.void), + { + startImmediately: true, + }, + ); + yield* Effect.yieldNow; + expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( + "A desktop app update is already in progress.", + ); + yield* Fiber.interrupt(retry); + }), + ); +}); diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts new file mode 100644 index 0000000000000..9770aa083a2f7 --- /dev/null +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -0,0 +1,219 @@ +import { + ServerSelfUpdateError, + type DesktopUpdateState, + type DesktopUpdateStatusReport, + type ServerSelfUpdateProgressStage, + type ServerSelfUpdateResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +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 Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; + +import { ServerConfig } from "../config.ts"; +import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; + +/** Backstop for a desktop updater that hangs without ever reporting a + terminal outcome. Generous: it covers a slow download of a full build. */ +const DESKTOP_UPDATE_TIMEOUT = Duration.minutes(20); +const DESKTOP_INSTALL_TIMEOUT = Duration.minutes(2); + +/** Progress stage a desktop update state maps to, or null when the state + carries no progress worth streaming. */ +export function desktopUpdateProgressStage( + state: DesktopUpdateState, +): ServerSelfUpdateProgressStage | null { + switch (state.status) { + case "checking": + case "available": + case "downloading": + return "downloading"; + case "downloaded": + return "installing"; + default: + return null; + } +} + +export class DesktopAppUpdate extends Context.Service< + DesktopAppUpdate, + { + /** True when this server was spawned by a desktop app that can be + driven over the telemetry control channel. */ + readonly available: boolean; + /** Checks and downloads through the desktop app, then returns a token + while this server is still connected. `commit` starts installation. */ + readonly run: ( + reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, + ) => Effect.Effect; + /** Starts the prepared install. Success stops this server, so this effect + returns only when installation fails or times out. */ + readonly commit: (requestId: string) => Effect.Effect; + } +>()("t3/desktopUpdate/DesktopAppUpdate") {} + +export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* () { + const config = yield* ServerConfig; + const crypto = yield* Crypto.Crypto; + const receiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; + const inFlight = yield* Ref.make(false); + + const available = config.mode === "desktop" && config.desktopTelemetryControlFd !== undefined; + const failWith = (reason: string, cause?: unknown) => + cause === undefined + ? new ServerSelfUpdateError({ reason }) + : new ServerSelfUpdateError({ reason, cause }); + + const consumeReports = ( + requestId: string, + changes: Stream.Stream, + reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, + ) => + Effect.gen(function* () { + const lastStage = yield* Ref.make(null); + const emitStage = (stage: ServerSelfUpdateProgressStage | null): Effect.Effect => + stage === null + ? Effect.void + : Ref.get(lastStage).pipe( + Effect.flatMap((previous) => + previous === stage + ? Effect.void + : Ref.set(lastStage, stage).pipe(Effect.andThen(reportProgress(stage))), + ), + ); + + const terminal = yield* changes.pipe( + Stream.filter((report) => report.requestId === requestId), + Stream.mapEffect( + (report): Effect.Effect> => + report.outcome === undefined + ? emitStage(desktopUpdateProgressStage(report.state)).pipe( + Effect.as(Option.none()), + ) + : Effect.succeed(Option.some(report)), + ), + Stream.filterMap( + Option.match({ + onNone: () => Result.failVoid, + onSome: Result.succeed, + }), + ), + Stream.runHead, + ); + if (Option.isNone(terminal)) { + return yield* failWith("The desktop app stopped reporting its update."); + } + + const report = terminal.value; + if (report.outcome === "ready-to-install") { + yield* emitStage("installing"); + const targetVersion = + report.state.downloadedVersion ?? + report.state.availableVersion ?? + report.state.currentVersion; + yield* Effect.logInfo("Desktop app update prepared for install.", { + targetVersion, + }); + yield* Ref.set(inFlight, false); + return { targetVersion, method: "desktop-app" as const, desktopUpdateToken: requestId }; + } + if (report.outcome === "up-to-date") { + return yield* failWith( + `The T3 Code desktop app on this machine is already up to date on ${report.state.currentVersion}.`, + ); + } + return yield* failWith( + report.reason ?? report.state.message ?? "The desktop app update failed.", + ); + }); + + const run: DesktopAppUpdate["Service"]["run"] = Effect.fn("desktopUpdate.desktopAppUpdate.run")( + function* (reportProgress) { + if (!available) { + return yield* failWith( + "This server was not started by the T3 Code desktop app, so it cannot drive a desktop update.", + ); + } + if (yield* Ref.getAndSet(inFlight, true)) { + return yield* failWith("A desktop app update is already in progress."); + } + + return yield* Effect.scoped( + Effect.gen(function* () { + const requestId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((error) => + failWith("Could not generate a desktop update request id.", error), + ), + ); + // Subscribe before sending the request so a fast first report + // cannot be missed. + const { changes } = yield* receiver.desktopUpdates; + yield* receiver + .requestDesktopUpdate(requestId) + .pipe( + Effect.mapError((error) => + failWith("Could not reach the T3 Code desktop app on this machine.", error), + ), + ); + return yield* consumeReports(requestId, changes, reportProgress).pipe( + Effect.onInterrupt(() => receiver.cancelDesktopUpdate(requestId).pipe(Effect.ignore)), + ); + }), + ).pipe( + Effect.timeout(DESKTOP_UPDATE_TIMEOUT), + Effect.catchTags({ + TimeoutError: () => failWith("The desktop app did not finish the update in time."), + }), + Effect.onError(() => Ref.set(inFlight, false)), + ); + }, + ); + + const commit: DesktopAppUpdate["Service"]["commit"] = Effect.fn( + "desktopUpdate.desktopAppUpdate.commit", + )(function* (requestId) { + if (!available) { + return yield* failWith("This server cannot commit a desktop app update."); + } + const terminal = yield* Effect.scoped( + Effect.gen(function* () { + const { latest, changes } = yield* receiver.desktopUpdates; + const reports = Option.match(latest, { + onNone: () => changes, + onSome: (report) => Stream.concat(Stream.make(report), changes), + }); + yield* receiver + .commitDesktopUpdate(requestId) + .pipe( + Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app.", error)), + ); + return yield* reports.pipe( + Stream.filter((report) => report.requestId === requestId && report.outcome === "failed"), + Stream.runHead, + Effect.timeout(DESKTOP_INSTALL_TIMEOUT), + Effect.catchTags({ + TimeoutError: () => + failWith("The desktop app did not report an install result in time."), + }), + ); + }), + ); + if (Option.isNone(terminal)) { + return yield* failWith("The desktop app stopped reporting the install."); + } + return yield* failWith( + terminal.value.reason ?? + terminal.value.state.message ?? + "The desktop app failed to install the update.", + ); + }); + + return DesktopAppUpdate.of({ available, run, commit }); +}); + +export const layer = Layer.effect(DesktopAppUpdate, make()); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 32006f691ed7f..3b68c1024dfbe 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -218,6 +218,43 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }), ); + it.effect("advertises desktopAppUpdate only with desktop mode and the control fd", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-desktop-update-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + + const describeWith = (overrides: Partial) => + Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerConfig.layer({ ...serverConfig, ...overrides })), + ), + ), + ); + + const withFd = yield* describeWith({ mode: "desktop", desktopTelemetryControlFd: 5 }); + expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed"); + expect(withFd.capabilities.desktopAppUpdate).toBe(true); + expect(withFd.capabilities.serverSelfUpdateProgress).toBe(true); + + const withoutFd = yield* describeWith({ mode: "desktop" }); + expect(withoutFd.capabilities.serverSelfUpdate).toBe("desktop-managed"); + expect(withoutFd.capabilities.desktopAppUpdate).toBeUndefined(); + expect(withoutFd.capabilities.serverSelfUpdateProgress).toBeUndefined(); + + const web = yield* describeWith({ mode: "web", desktopTelemetryControlFd: 5 }); + expect(web.capabilities.desktopAppUpdate).toBeUndefined(); + }), + ); + it.effect("structures persisted environment id filesystem failures", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0de59db781603..366b2f5b41b0e 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -193,6 +193,12 @@ export const make = Effect.gen(function* () { desktopManaged: serverConfig.mode === "desktop", launcherManaged: launcher.managed, }); + // Static is correct: the control fd is known at bootstrap, and the desktop + // app and its bundled server ship in one artifact, so a present fd means + // the app speaks the requestDesktopUpdate protocol. WSL backends never get + // the fd and correctly do not advertise. + const desktopAppUpdate = + serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -217,7 +223,10 @@ export const make = Effect.gen(function* () { threadTitleRegeneration: true, threadPullRequestLinking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), - ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), + ...(serverSelfUpdate === "boot-service" || desktopAppUpdate + ? { serverSelfUpdateProgress: true } + : {}), + ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), }, }; diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 1fca5696ab67b..0e4b99f9f1eb4 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -7,6 +7,7 @@ import { type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue, type DesktopHostTelemetrySnapshot, DesktopTelemetryControlMessage, + type DesktopUpdateStatusReport, type ResourceTelemetrySourceStatus, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; @@ -171,6 +172,29 @@ export class DesktopTelemetryReceiver extends Context.Service< readonly setDiagnosticsDemand: ( enabled: boolean, ) => Effect.Effect; + /** Asks the desktop app supervising this server to update itself. The + desktop answers with desktopUpdateStatus reports carrying the same + requestId. */ + readonly requestDesktopUpdate: ( + requestId: string, + ) => Effect.Effect; + readonly commitDesktopUpdate: ( + requestId: string, + ) => Effect.Effect; + readonly cancelDesktopUpdate: ( + requestId: string, + ) => Effect.Effect; + /** Latest desktop update state report plus subsequent reports. The + desktop replays its latest report when the backend attaches, so this + is populated shortly after startup on desktop-managed servers. */ + readonly desktopUpdates: Effect.Effect< + { + readonly latest: Option.Option; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; } >()("t3/resourceTelemetry/DesktopTelemetryReceiver") {} @@ -322,6 +346,8 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") ); const changes = yield* PubSub.sliding(8); const healthChanges = yield* PubSub.sliding(4); + const latestUpdateReport = yield* Ref.make(Option.none()); + const updateReportChanges = yield* PubSub.sliding(16); const controlMutex = yield* Semaphore.make(1); const snapshotMutex = yield* Semaphore.make(1); const health = yield* Ref.make({ @@ -503,6 +529,15 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") ); } + // Not a resource sample: do not touch `latest` or sample health. + if (message.type === "desktopUpdateStatus") { + return recordContact.pipe( + Effect.andThen(Ref.set(latestUpdateReport, Option.some(message))), + Effect.andThen(PubSub.publish(updateReportChanges, message)), + Effect.asVoid, + ); + } + const sampledAt = DateTime.makeUnsafe(message.sampledAtUnixMs); return snapshotMutex.withPermits(1)( recordContact.pipe( @@ -616,6 +651,24 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") health: Ref.get(health), subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), setDiagnosticsDemand, + requestDesktopUpdate: (requestId) => + sendControlMessage({ + version: 1, + type: "requestDesktopUpdate", + requestId, + }), + commitDesktopUpdate: (requestId) => + sendControlMessage({ version: 1, type: "commitDesktopUpdate", requestId }), + cancelDesktopUpdate: (requestId) => + sendControlMessage({ version: 1, type: "cancelDesktopUpdate", requestId }), + desktopUpdates: Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(updateReportChanges); + const initial = yield* Ref.get(latestUpdateReport); + return { + latest: initial, + changes: Stream.fromSubscription(subscription), + }; + }), }); }); @@ -656,6 +709,15 @@ export const layerTest = ( })), ), setDiagnosticsDemand: () => Effect.void, + requestDesktopUpdate: () => Effect.void, + commitDesktopUpdate: () => Effect.void, + cancelDesktopUpdate: () => Effect.void, + desktopUpdates: + overrides.desktopUpdates ?? + Effect.succeed({ + latest: Option.none(), + changes: Stream.empty, + }), ...overrides, }), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 631902ac087d5..9ae28080b048e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -102,6 +102,7 @@ import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts" import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; @@ -173,6 +174,12 @@ const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe( Layer.provide(DesktopTelemetryReceiverLayerLive), ); +// Reuses DesktopTelemetryReceiverLayerLive: a fresh receiver layer here +// would open a second reader on the desktop telemetry fd. +const DesktopAppUpdateLayerLive = DesktopAppUpdate.layer.pipe( + Layer.provide(DesktopTelemetryReceiverLayerLive), +); + const BackgroundLayerLive = BackgroundPolicy.layer.pipe( Layer.provide(HostPowerMonitorLayerLive), Layer.provideMerge(ServerSettingsLayerLive), @@ -491,7 +498,7 @@ export const makeRoutesLayer = Layer.mergeAll( // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(PullRequestServiceLive), Layer.provide(PreviewAutomationBroker.layer), - Layer.provide(ServerSelfUpdate.layer), + Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(DesktopAppUpdateLayerLive))), Layer.provide(commandReadinessLayer), Layer.provide(browserApiCorsLayer), Layer.provide(httpCompressionLayer), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 53f1b6734e857..a78030f488074 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1721,6 +1721,12 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverCommitDesktopUpdate]: (input) => + observeRpcEffect( + WS_METHODS.serverCommitDesktopUpdate, + serverSelfUpdate.commitDesktopUpdate(input.requestId), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dc0d2ed122f5b..77052af0ac828 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -423,6 +423,7 @@ import { resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, serverUpdateGuidance, + supportsDesktopAppUpdate, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; @@ -2186,6 +2187,7 @@ function ChatViewContent(props: ChatViewProps) { : "server"; const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); + const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); const serverUpdateState = useAtomValue( serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), ); @@ -2306,19 +2308,25 @@ function ChatViewContent(props: ChatViewProps) { "Server update available" ), description: - !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed" + !updateInProgress && + !updateFailed && + versionMismatchSelfUpdate === "desktop-managed" && + !versionMismatchDesktopAppUpdate ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel) : undefined, // The desktop-managed guidance is already the description; the action - // slot would only repeat it. + // slot would only repeat it. When the desktop app accepts remote + // update requests, the action button takes over instead. actions: updateInProgress || !versionMismatch || - versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( + (versionMismatchSelfUpdate === "desktop-managed" && + !versionMismatchDesktopAppUpdate) ? undefined : ( @@ -2352,6 +2360,7 @@ function ChatViewContent(props: ChatViewProps) { versionMismatchDismissKey, serverUpdateEnvironmentId, versionMismatchSelfUpdate, + versionMismatchDesktopAppUpdate, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 2928848959f24..0bc5ce7e84aa2 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -98,6 +98,49 @@ describe("ServerUpdateAction", () => { expect(testState.toast).not.toHaveBeenCalled(); }); + + it("keeps the manual instruction for desktop servers without remote update support", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Update the desktop app on that machine to update this server."); + expect(markup).not.toContain(" { + testState.updateServer.mockResolvedValue( + AsyncResult.success({ targetVersion: "0.0.34", method: "desktop-app" as const }), + ); + + const action = ServerUpdateAction({ + environmentId: "env-test" as EnvironmentId, + serverLabel: "Test server", + selfUpdate: "desktop-managed", + desktopAppUpdate: true, + targetVersion: "0.0.31", + }) as ActionElement; + + // No confirm-dialog host is mounted in this test, which the component + // treats as consent: the click itself was the request. + action.props.onClick?.(); + await flushPromises(); + + expect(testState.updateServer).toHaveBeenCalledWith({ + environmentId: "env-test", + input: { targetVersion: "0.0.31" }, + }); + expect(testState.toast).toHaveBeenCalledWith({ + type: "success", + title: "Test server updated", + description: "Desktop app relaunched on 0.0.34.", + }); + }); }); describe("ServerUpdateProgress", () => { diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 06b082ca65427..732a62be8aa64 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -5,6 +5,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { serverEnvironment } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -75,15 +76,20 @@ export function ServerUpdateAction({ environmentId, serverLabel, selfUpdate, + desktopAppUpdate = false, targetVersion, label = "Update", }: { readonly environmentId: EnvironmentId; readonly serverLabel: string; readonly selfUpdate: ServerSelfUpdateCapability | null; + /** The desktop app supervising this server accepts remote update + requests (capabilities.desktopAppUpdate). */ + readonly desktopAppUpdate?: boolean; readonly targetVersion: string; readonly label?: string; }) { + const isDesktopAppUpdate = selfUpdate === "desktop-managed"; const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false, }); @@ -106,6 +112,21 @@ export function ServerUpdateAction({ }); const handleUpdate = async () => { + if (pendingUpdateEnvironmentIds.has(environmentId)) { + return; + } + if (isDesktopAppUpdate) { + // No themed host mounted (undefined) means proceed: the click itself + // was the request. This is the only confirmation in the flow; the + // remote machine installs without asking anyone there. + const confirmed = + (await requestConfirmDialog( + `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, + )) ?? true; + if (!confirmed) { + return; + } + } if (pendingUpdateEnvironmentIds.has(environmentId)) { return; } @@ -129,14 +150,16 @@ export function ServerUpdateAction({ toastManager.add({ type: "success", title: `${serverLabel} updated`, - description: `Reconnected on t3@${result.value.targetVersion}.`, + description: isDesktopAppUpdate + ? `Desktop app relaunched on ${result.value.targetVersion}.` + : `Reconnected on t3@${result.value.targetVersion}.`, }); } finally { pendingUpdateEnvironmentIds.delete(environmentId); } }; - if (selfUpdate === "desktop-managed") { + if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { return ( Update the desktop app on that machine to update this server. diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 6ca31aa626f64..2c516fffe0c4f 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -104,6 +104,7 @@ import { useUiStateStore } from "~/uiStateStore"; import { resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, + supportsDesktopAppUpdate, } from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; @@ -1485,6 +1486,7 @@ function SavedBackendListRow({ environmentId={environmentId} serverLabel={`${environment.label} server`} selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} + desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} targetVersion={versionMismatch.clientVersion} label={serverUpdateState.status === "failed" ? "Retry" : "Update"} /> @@ -3057,8 +3059,11 @@ export function ConnectionsSettings() { primaryServerUpdateState.status !== "running" ? ( diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index bb1febf9850a8..cb357c5377af2 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -18,6 +18,7 @@ import { resolveServerSelfUpdateCapability, resolveVersionMismatch, serverUpdateGuidance, + supportsDesktopAppUpdate, } from "./versionSkew"; const MISMATCH_HINT = @@ -192,6 +193,27 @@ describe("versionSkew", () => { expect(resolveServerSelfUpdateCapability(null)).toBeNull(); }); + it("detects remote desktop-app update support from config descriptors", () => { + const descriptor = (desktopAppUpdate?: boolean) => ({ + environment: { + environmentId: EnvironmentId.make("environment-desktop"), + label: "Desktop", + platform: { os: "darwin", arch: "arm64" } as const, + serverVersion: "9.9.9", + capabilities: { + repositoryIdentity: true, + serverSelfUpdate: "desktop-managed" as const, + ...(desktopAppUpdate === undefined ? {} : { desktopAppUpdate }), + }, + }, + }); + + expect(supportsDesktopAppUpdate(descriptor(true))).toBe(true); + expect(supportsDesktopAppUpdate(descriptor(false))).toBe(false); + expect(supportsDesktopAppUpdate(descriptor())).toBe(false); + expect(supportsDesktopAppUpdate(null)).toBe(false); + }); + it("matches version-drift guidance to the advertised update path", () => { expect(serverUpdateGuidance("respawn", "Remote server")).toBe( "Update the Remote server so they stay in sync.", diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index 20f451851fdd3..a9f8f3e09fab3 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -98,6 +98,14 @@ export function resolveServerSelfUpdateCapability( return serverConfig?.environment.capabilities.serverSelfUpdate ?? null; } +/** True when the desktop app supervising this server can be told to update + itself over RPC. Older desktop servers only get the manual instruction. */ +export function supportsDesktopAppUpdate( + serverConfig: Pick | null | undefined, +): boolean { + return serverConfig?.environment.capabilities.desktopAppUpdate === true; +} + /** The command to hand users whose server cannot update itself. */ export function manualServerUpdateCommand(targetVersion: string): string { return `npx t3@${targetVersion}`; diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 4648fa1fd257f..693a240bbc79a 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -85,8 +85,16 @@ recorded reason. Older servers without an ID retain version-only reconnect behav The existing additive RPC and lifecycle schemas remain compatible with older clients. New servers advertise remote self-update only when they have valid launcher context and a live IPC channel. -Desktop-managed servers direct the user to update the desktop app. Other process shapes provide a -manual command; the old detached foreground respawn path no longer exists. +Desktop-managed servers advertise `desktopAppUpdate` when the desktop telemetry control fd is +attached. The progress RPC asks the desktop app to check and download, then returns a preparation +token while the backend is still connected. Only after the client receives that result does it send +`server.commitDesktopUpdate`. A successful commit closes the connection and must reconnect at the +prepared version. The desktop app and bundled server versions stay equal because +`scripts/update-release-package-versions.ts` bumps them together. If install fails, the desktop keeps its windows, restarts stopped backends, and +replays the failure for the same token. This two-phase handoff prevents backend shutdown from +dropping the only successful RPC result. Desktop servers without the capability direct the user to +update the desktop app locally. Other process shapes provide a manual command; the old detached +foreground respawn path no longer exists. ## Source Map diff --git a/docs/user/updating.md b/docs/user/updating.md index ec893df8903b4..022ce031e3307 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -22,11 +22,11 @@ The update does not remove saved threads, settings, or project files. ## Choose the Action You See -| Action | What to do | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Update server** | Available for the T3 Code Linux background service. Select the button and leave T3 Code open while it prepares, tests, restarts, and reconnects. | -| **Update the desktop app** | Open the T3 Code desktop app on the machine that runs the server and install the app update there. Reopen it if needed. | -| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current T3 Code server, and relaunch it with the copied command and any startup options you normally use. | +| Action | What to do | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Update server** | Available for the T3 Code Linux background service and for servers run by a current T3 Code desktop app. Select the button and leave T3 Code open while it downloads, installs, restarts, and reconnects. For desktop-app servers this closes and relaunches the desktop app on that machine. If installation fails, the desktop app stays open and reconnects to its server. | +| **Update the desktop app** | Shown for desktop apps that predate remote updates. Open the T3 Code desktop app on the machine that runs the server and install the app update there. Reopen it if needed. | +| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current T3 Code server, and relaunch it with the copied command and any startup options you normally use. | The available action depends on how that server was started. T3 Code does not update connected servers silently in the background. diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 4dbbfe45fe009..15df040643ea7 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -50,6 +50,7 @@ import * as EnvironmentRegistry from "./registry.ts"; import * as RpcSession from "../rpc/session.ts"; import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionWakeups from "./wakeups.ts"; +import { runDesktopCommitWithReconnectObserver } from "../state/server.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -426,6 +427,33 @@ function awaitConnectionState( } describe("EnvironmentRegistry", () => { + it.effect("replays connected state when arming a desktop commit observer", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const commits = yield* Ref.make(0); + const result = yield* runDesktopCommitWithReconnectObserver( + registry.stateChanges(TARGET.environmentId), + Ref.update(commits, (count) => count + 1).pipe( + Effect.andThen(Effect.fail("commit refused")), + ), + ).pipe(Effect.flip, Effect.timeout("1 second")); + + expect(result).toBe("commit refused"); + expect(yield* Ref.get(commits)).toBe(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("does not acquire a session after the registry scope has already closed", () => Effect.gen(function* () { const harness = yield* makeHarness([TARGET]); diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 745db2b9973fb..3f12f44d5f4cf 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; import { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Latch from "effect/Latch"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -833,6 +835,55 @@ describe("runtime command runner", () => { }), ); + it.effect("keeps an outer reconnect observer alive after a scheduled handoff returns", () => + Effect.gen(function* () { + const commitStarting = yield* Deferred.make(); + const observerArmed = yield* Deferred.make(); + const reconnected = yield* Deferred.make(); + const states = yield* Queue.unbounded<{ readonly phase: string }>(); + yield* Queue.offer(states, { phase: "connected" }); + const runtime = Atom.runtime(Layer.empty); + const scheduler = createAtomCommandScheduler(); + const concurrency = { mode: "serial" as const, key: () => "shared" }; + const command = createRuntimeCommand(runtime, { + label: "test.desktop-update-handoff", + execute: (_input: void, registry) => + Effect.gen(function* () { + yield* Deferred.await(commitStarting).pipe( + Effect.andThen( + Stream.fromQueue(states).pipe( + Stream.tap(() => Deferred.succeed(observerArmed, undefined)), + Stream.dropWhile((state) => state.phase === "connected"), + Stream.filter((state) => state.phase === "connected"), + Stream.runHead, + ), + ), + Effect.andThen(Deferred.succeed(reconnected, undefined)), + Effect.forkChild, + ); + yield* scheduleAtomCommandEffect( + registry, + scheduler, + concurrency, + undefined, + Deferred.succeed(commitStarting, undefined).pipe( + Effect.andThen(Deferred.await(observerArmed)), + ), + ); + yield* Queue.offer(states, { phase: "backoff" }); + yield* Queue.offer(states, { phase: "connected" }); + yield* Deferred.await(reconnected); + }), + }); + const registry = AtomRegistry.make(); + + expect(yield* Effect.promise(() => command.run(registry, undefined))).toMatchObject({ + _tag: "Success", + }); + registry.dispose(); + }), + ); + it("deduplicates single-flight commands by key", async () => { const latch = Latch.makeUnsafe(); let executions = 0; diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index ea170c22e830d..0e6ffb24e7901 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -33,6 +34,7 @@ import { makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, + matchesServerUpdateResumeEvent, nudgeReconnectDuringUpdateRestart, projectServerWelcome, resolveServerConfigValue, @@ -40,6 +42,9 @@ import { serverUpdateStateForProgressEvent, serverUpdateStateForServerVersion, validateServerUpdateReadyEvent, + waitForNextEnvironmentReconnect, + waitForDesktopUpdateTarget, + runDesktopCommitWithReconnectObserver, } from "./server.ts"; import { applyServerConfigProjection } from "./serverConfigProjection.ts"; @@ -81,6 +86,83 @@ function session(client: WsRpcProtocolClient): RpcSession { } describe("update restart reconnect nudges", () => { + it.effect("retries a desktop commit that was lost before delivery", () => + Effect.gen(function* () { + const readyEvents = + yield* Queue.unbounded[1]>(); + const ready = (serverVersion: string) => + ({ + version: 1 as const, + sequence: 1, + type: "ready" as const, + payload: { + at: "2026-09-01T00:00:00.000Z", + environment: { serverVersion }, + }, + }) as Parameters[1]; + yield* Queue.offerAll(readyEvents, [ready("0.0.30"), ready("0.0.31")]); + const retries = yield* Ref.make(0); + const disconnect = new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006 }), + }); + + const result = yield* waitForDesktopUpdateTarget( + "0.0.31", + Queue.take(readyEvents), + Ref.update(retries, (count) => count + 1).pipe(Effect.andThen(Effect.fail(disconnect))), + ); + + expect(result.payload.environment.serverVersion).toBe("0.0.31"); + expect(yield* Ref.get(retries)).toBe(1); + }), + ); + it.effect("observes a fast reconnect even when the caller awaits it later", () => + Effect.gen(function* () { + const states = yield* Queue.unbounded<{ readonly phase: string }>(); + const reconnected = yield* waitForNextEnvironmentReconnect(Stream.fromQueue(states)).pipe( + Effect.forkChild, + ); + yield* Queue.offerAll(states, [ + { phase: "connected" }, + { phase: "backoff" }, + { phase: "connected" }, + ]); + + yield* Fiber.join(reconnected); + }), + ); + it.effect("arms the retry observer before a commit can disconnect", () => + Effect.gen(function* () { + const allowSubscription = yield* Deferred.make(); + const subscriptionStarted = yield* Deferred.make(); + const states = yield* Queue.unbounded<{ readonly phase: string }>(); + const commits = yield* Ref.make(0); + const disconnect = new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006 }), + }); + const stateChanges = Stream.unwrap( + Deferred.succeed(subscriptionStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowSubscription)), + Effect.as(Stream.fromQueue(states)), + ), + ); + const retry = yield* runDesktopCommitWithReconnectObserver( + stateChanges, + Ref.update(commits, (count) => count + 1).pipe( + Effect.andThen(Queue.offerAll(states, [{ phase: "backoff" }, { phase: "connected" }])), + Effect.andThen(Effect.fail(disconnect)), + ), + ).pipe(Effect.flip, Effect.forkChild); + + yield* Deferred.await(subscriptionStarted); + expect(yield* Ref.get(commits)).toBe(0); + yield* Deferred.succeed(allowSubscription, undefined); + yield* Queue.offer(states, { phase: "connected" }); + + expect(yield* Fiber.join(retry)).toBe(disconnect); + expect(yield* Ref.get(commits)).toBe(1); + }), + ); it.effect("retries once per backoff entry instead of only the first", () => Effect.gen(function* () { const retries = yield* Ref.make(0); @@ -292,6 +374,36 @@ describe("server state projection", () => { }), ); + it("requires tokenless desktop updates to reach the target version", () => { + const ready = (serverVersion: string) => + ({ + version: 1 as const, + sequence: 1, + type: "ready" as const, + payload: { + at: "2026-09-01T00:00:00.000Z", + environment: { serverVersion }, + }, + }) as Parameters[1]; + + expect( + matchesServerUpdateResumeEvent( + { targetVersion: "0.0.31", method: "desktop-app" }, + ready("0.0.30"), + ), + ).toBe(false); + expect( + matchesServerUpdateResumeEvent( + { + targetVersion: "0.0.31", + method: "desktop-app", + desktopUpdateToken: "update-1", + }, + ready("0.0.30"), + ), + ).toBe(true); + }); + it("applies every config category to the projected snapshot", () => { const snapshot = applyServerConfigProjection(Option.none(), { version: 1, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 8ce8b46f058a0..ab2c9b8266884 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -10,8 +10,10 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -132,6 +134,16 @@ export function matchesServerUpdateReadyEvent( : event.payload.updateOutcome?.id === result.updateId; } +export function matchesServerUpdateResumeEvent( + result: ServerSelfUpdateResult, + event: ServerLifecycleStreamReadyEvent, +): boolean { + return ( + (result.method === "desktop-app" && result.desktopUpdateToken !== undefined) || + matchesServerUpdateReadyEvent(result, event) + ); +} + export function validateServerUpdateReadyEvent( result: ServerSelfUpdateResult, event: ServerLifecycleStreamReadyEvent, @@ -200,6 +212,71 @@ export function nudgeReconnectDuringUpdateRestart(input: { ); } +export function waitForNextEnvironmentReconnect( + stateChanges: Stream.Stream<{ readonly phase: string }, E>, +): Effect.Effect { + return stateChanges.pipe( + Stream.dropWhile((state) => state.phase === "connected"), + Stream.filter((state) => state.phase === "connected"), + Stream.runHead, + Effect.asVoid, + ); +} + +export const runDesktopCommitWithReconnectObserver = Effect.fn( + "runDesktopCommitWithReconnectObserver", +)(function* ( + stateChanges: Stream.Stream<{ readonly phase: string }, EState>, + commit: Effect.Effect, +) { + const armed = yield* Deferred.make(); + const reconnected = yield* Deferred.make(); + const observer = yield* stateChanges.pipe( + Stream.tap(() => Deferred.succeed(armed, undefined)), + waitForNextEnvironmentReconnect, + Effect.andThen(Deferred.succeed(reconnected, undefined)), + Effect.forkChild, + ); + yield* Deferred.await(armed); + const commitExit = yield* commit.pipe(Effect.exit); + if (Exit.isSuccess(commitExit)) { + yield* Fiber.interrupt(observer); + return; + } + if (!isLegacyUpdateHandoffLoss(commitExit.cause)) { + yield* Fiber.interrupt(observer); + return yield* Effect.failCause(commitExit.cause); + } + yield* Deferred.await(reconnected).pipe(Effect.timeout(SERVER_UPDATE_RESUME_TIMEOUT)); + return yield* Effect.failCause(commitExit.cause); +}); + +export const waitForDesktopUpdateTarget = Effect.fn("waitForDesktopUpdateTarget")(function* < + EReady, + ECommit, +>( + targetVersion: string, + nextReady: Effect.Effect, + retryCommit: Effect.Effect, + maxCommitAttempts = 3, +): Effect.fn.Return { + for (let attempt = 1; attempt <= maxCommitAttempts; attempt += 1) { + const ready = yield* nextReady; + if (ready.payload.environment.serverVersion === targetVersion) return ready; + if (attempt === maxCommitAttempts) break; + const retryExit = yield* retryCommit.pipe(Effect.exit); + if (Exit.isSuccess(retryExit)) break; + if (!isLegacyUpdateHandoffLoss(retryExit.cause)) { + return yield* Effect.failCause(retryExit.cause); + } + } + return yield* new ServerUpdateTerminalError({ + targetVersion, + status: "failed", + reason: "The desktop app resumed without installing the prepared update.", + }); +}); + export function serverUpdateStateForProgressEvent( fromVersion: string, targetVersion: string, @@ -497,11 +574,12 @@ export function createServerEnvironmentAtoms( concurrency: configConcurrency, execute: (target, atomRegistry) => { const stateAtom = serverUpdateStateAtom(target.environmentId); - const targetVersion = target.input.targetVersion; + let targetVersion = target.input.targetVersion; let fromVersion = atomRegistry.get(configValueAtom(target.environmentId))?.environment.serverVersion ?? targetVersion; let currentStage: ServerUpdateStage = "downloading"; + let desktopCommitLostTransport = false; atomRegistry.set(stateAtom, { status: "running", stage: currentStage, @@ -511,6 +589,21 @@ export function createServerEnvironmentAtoms( return Effect.gen(function* () { const environmentRegistry = yield* EnvironmentRegistry; + const desktopCommitStarting = yield* Deferred.make(); + const desktopReconnectObserverArmed = yield* Deferred.make(); + const desktopReconnected = yield* Deferred.make(); + yield* Deferred.await(desktopCommitStarting).pipe( + Effect.andThen( + environmentRegistry.stateChanges(target.environmentId).pipe( + Stream.tap(() => Deferred.succeed(desktopReconnectObserverArmed, undefined)), + Stream.dropWhile((state) => state.phase === "connected"), + Stream.filter((state) => state.phase === "connected"), + Stream.runHead, + ), + ), + Effect.andThen(Deferred.succeed(desktopReconnected, undefined)), + Effect.forkChild, + ); const result = yield* scheduleAtomCommandEffect( atomRegistry, configScheduler, @@ -582,6 +675,28 @@ export function createServerEnvironmentAtoms( return yield* Effect.failCause(exit.cause); }); + if ( + updateResult.method === "desktop-app" && + updateResult.desktopUpdateToken !== undefined + ) { + yield* Deferred.succeed(desktopCommitStarting, undefined); + yield* Deferred.await(desktopReconnectObserverArmed); + const commitExit = yield* environmentRegistry + .run( + target.environmentId, + request(WS_METHODS.serverCommitDesktopUpdate, { + requestId: updateResult.desktopUpdateToken, + }), + ) + .pipe(Effect.exit); + if (Exit.isFailure(commitExit) && !isLegacyUpdateHandoffLoss(commitExit.cause)) { + return yield* Effect.failCause(commitExit.cause); + } + desktopCommitLostTransport = Exit.isFailure(commitExit); + } + + targetVersion = updateResult.targetVersion; + currentStage = "resuming"; atomRegistry.set(stateAtom, { status: "running", @@ -601,24 +716,55 @@ export function createServerEnvironmentAtoms( retryNow: environmentRegistry.retryNow(target.environmentId), }).pipe(Effect.forkChild); - const resumed = yield* environmentRegistry + if (result.method === "desktop-app" && desktopCommitLostTransport) { + yield* Deferred.await(desktopReconnected).pipe( + Effect.timeout(SERVER_UPDATE_RESUME_TIMEOUT), + ); + } + + const waitForReady = environmentRegistry .followStream(target.environmentId, subscribe(WS_METHODS.subscribeServerLifecycle, {})) .pipe( Stream.filter( (event): event is ServerLifecycleStreamReadyEvent => - event.type === "ready" && matchesServerUpdateReadyEvent(result, event), + event.type === "ready" && matchesServerUpdateResumeEvent(result, event), ), Stream.runHead, Effect.timeoutOption(SERVER_UPDATE_RESUME_TIMEOUT), Effect.map(Option.flatten), ); - if (Option.isNone(resumed)) { - return yield* new ServerUpdateResumeTimeoutError({ - environmentId: target.environmentId, - targetVersion, - }); - } - yield* validateServerUpdateReadyEvent(result, resumed.value); + const nextReady = waitForReady.pipe( + Effect.flatMap( + Option.match({ + onNone: () => + new ServerUpdateResumeTimeoutError({ + environmentId: target.environmentId, + targetVersion, + }), + onSome: Effect.succeed, + }), + ), + ); + const desktopUpdateToken = result.desktopUpdateToken; + const resumed = + result.method === "desktop-app" && desktopUpdateToken !== undefined + ? yield* waitForDesktopUpdateTarget( + result.targetVersion, + nextReady, + runDesktopCommitWithReconnectObserver( + environmentRegistry.stateChanges(target.environmentId), + environmentRegistry + .run( + target.environmentId, + request(WS_METHODS.serverCommitDesktopUpdate, { + requestId: desktopUpdateToken, + }), + ) + .pipe(Effect.asVoid), + ), + ) + : yield* nextReady; + yield* validateServerUpdateReadyEvent(result, resumed); atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE); return result; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 08e82599020a8..e68892d903cf7 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -30,8 +30,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T /** How a server can replace itself with another version when asked over RPC. New servers only advertise the stable launcher-backed "boot-service" path; - "respawn" remains decodable for compatibility with older servers. */ -export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]); + "respawn" remains decodable for compatibility with older servers. + "desktop-app" means the supervising desktop app updated and relaunched + itself, bringing the server back with it. */ +export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]); export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; /** What update path a client should offer for a server: one of the RPC @@ -97,6 +99,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ this is false — no update would ever repaint it. Absent on older servers, which may still publish, so only an explicit false skips. */ agentActivityPublishing: Schema.optionalKey(Schema.Boolean), + /** The desktop app supervising this server can be driven over RPC: + server.updateServer runs its check -> download -> relaunch. Absent on + desktop servers whose app predates the remote trigger, where clients + must keep telling the user to update the app on that machine. */ + desktopAppUpdate: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 2c1b2ffec6efb..3ec1e4de3ef48 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { HostPowerSnapshot } from "./background.ts"; +import { DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; @@ -244,9 +245,37 @@ export const DesktopHostTelemetryHello = Schema.Struct({ }); export type DesktopHostTelemetryHello = typeof DesktopHostTelemetryHello.Type; +/** Terminal marker for a server-triggered desktop update run. */ +export const DesktopUpdateRemoteOutcome = Schema.Literals([ + "ready-to-install", + "up-to-date", + "failed", +]); +export type DesktopUpdateRemoteOutcome = typeof DesktopUpdateRemoteOutcome.Type; + +/** + * Desktop main -> server: the desktop app's update state. Sent once when the + * backend attaches and again on every state change, so the server always + * knows whether the app on its machine can be updated and how a + * server-triggered run is progressing. + */ +export const DesktopUpdateStatusReport = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("desktopUpdateStatus"), + // Set while a server-triggered run owns the flow; absent for the attach + // snapshot and for locally driven update activity. + requestId: Schema.optionalKey(TrimmedNonEmptyString), + // Terminal marker for a server-triggered run; absent while it is working. + outcome: Schema.optionalKey(DesktopUpdateRemoteOutcome), + reason: Schema.optionalKey(TrimmedNonEmptyString), + state: DesktopUpdateStateSchema, +}); +export type DesktopUpdateStatusReport = typeof DesktopUpdateStatusReport.Type; + export const DesktopHostTelemetryMessage = Schema.Union([ DesktopHostTelemetryHello, DesktopHostTelemetrySnapshot, + DesktopUpdateStatusReport, ]); export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type; @@ -266,9 +295,38 @@ export const DesktopTelemetrySetHostPowerIntervals = Schema.Struct({ export type DesktopTelemetrySetHostPowerIntervals = typeof DesktopTelemetrySetHostPowerIntervals.Type; +/** + * Server -> desktop main: run the app's own update flow now (check -> + * download -> quit-and-install) with no local confirmation. The remote click + * on the machine that sent the RPC is the consent. + */ +export const DesktopTelemetryRequestDesktopUpdate = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("requestDesktopUpdate"), + requestId: TrimmedNonEmptyString, +}); +export type DesktopTelemetryRequestDesktopUpdate = typeof DesktopTelemetryRequestDesktopUpdate.Type; + +export const DesktopTelemetryCommitDesktopUpdate = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("commitDesktopUpdate"), + requestId: TrimmedNonEmptyString, +}); +export type DesktopTelemetryCommitDesktopUpdate = typeof DesktopTelemetryCommitDesktopUpdate.Type; + +export const DesktopTelemetryCancelDesktopUpdate = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("cancelDesktopUpdate"), + requestId: TrimmedNonEmptyString, +}); +export type DesktopTelemetryCancelDesktopUpdate = typeof DesktopTelemetryCancelDesktopUpdate.Type; + export const DesktopTelemetryControlMessage = Schema.Union([ DesktopTelemetrySetDiagnosticsDemand, DesktopTelemetrySetHostPowerIntervals, + DesktopTelemetryRequestDesktopUpdate, + DesktopTelemetryCommitDesktopUpdate, + DesktopTelemetryCancelDesktopUpdate, ]); export type DesktopTelemetryControlMessage = typeof DesktopTelemetryControlMessage.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 7cd674485f94b..7d6ba2a15b0c5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -166,6 +166,7 @@ import { } from "./previewAutomation.ts"; import { ServerConfigStreamEvent, + DesktopUpdateCommitInput, ServerConfig, ServerProviderUpdateError, ServerProviderUpdateInput, @@ -276,6 +277,7 @@ export const WS_METHODS = { serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", + serverCommitDesktopUpdate: "server.commitDesktopUpdate", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", @@ -393,6 +395,12 @@ export const WsServerUpdateServerWithProgressRpc = Rpc.make( }, ); +export const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUpdate, { + payload: DesktopUpdateCommitInput, + success: ServerSelfUpdateResult, + error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), +}); + export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1033,6 +1041,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpdateProviderRpc, WsServerUpdateServerRpc, WsServerUpdateServerWithProgressRpc, + WsServerCommitDesktopUpdateRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 1205f309c1787..333e9affc9fa3 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -755,9 +755,16 @@ export const ServerSelfUpdateResult = Schema.Struct({ method: ServerSelfUpdateMethod, /** Launcher-generated correlation ID. Absent when talking to older servers. */ updateId: Schema.optionalKey(TrimmedNonEmptyString), + /** Desktop preparation token. Present only for the desktop-app method. */ + desktopUpdateToken: Schema.optionalKey(TrimmedNonEmptyString), }); export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type; +export const DesktopUpdateCommitInput = Schema.Struct({ + requestId: TrimmedNonEmptyString, +}); +export type DesktopUpdateCommitInput = typeof DesktopUpdateCommitInput.Type; + export const ServerSelfUpdateProgressStage = Schema.Literals(["downloading", "installing"]); export type ServerSelfUpdateProgressStage = typeof ServerSelfUpdateProgressStage.Type; From c37fd136edf546fd58daeac146548436c1fa38c2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 19:16:09 -0700 Subject: [PATCH 061/136] test(server): measure shell, second client, and reconnect transfer (#9157) Co-authored-by: Claude Fable 5.1 --- .../NetworkTransferMeasurement.integration.ts | 60 ++++- .../OrchestrationEngineHarness.integration.ts | 6 + .../SqlStatementCounter.integration.ts | 24 ++ .../TransferBudgetReport.integration.ts | 82 ++++++ .../TransferBudgetScenario.integration.ts | 84 ++++++ apps/server/src/server.test.ts | 245 +++++++++++++----- 6 files changed, 436 insertions(+), 65 deletions(-) create mode 100644 apps/server/integration/SqlStatementCounter.integration.ts diff --git a/apps/server/integration/NetworkTransferMeasurement.integration.ts b/apps/server/integration/NetworkTransferMeasurement.integration.ts index 75714d1519e23..c18b7d14bfe4a 100644 --- a/apps/server/integration/NetworkTransferMeasurement.integration.ts +++ b/apps/server/integration/NetworkTransferMeasurement.integration.ts @@ -5,8 +5,10 @@ import * as NodeZlib from "node:zlib"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import { WsRpcGroup } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; @@ -101,6 +103,8 @@ export interface WebSocketTransferRecorder { ) => globalThis.WebSocket; readonly totals: () => WebSocketTransferTotals; readonly negotiatedExtensions: () => string; + /** Resolves once the upgrade completes, so totals taken after it exclude the upgrade response. */ + readonly awaitOpen: Effect.Effect; } interface NodeWebSocketWithTransport extends NodeSocket.NodeWS.WebSocket { @@ -118,8 +122,15 @@ function rawDataBytes(data: NodeSocket.NodeWS.RawData): number { export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { let socket: NodeWebSocketWithTransport | null = null; + // Held separately from the WebSocket so wire totals survive a close, which + // is when a reconnect measurement reads them. + let transport: NodeWebSocketWithTransport["_socket"] | null = null; let decodedBytes = 0; let messages = 0; + let resolveOpen: () => void = () => {}; + const opened = new Promise((resolve) => { + resolveOpen = resolve; + }); return { connect: (url, protocols, cookie) => { @@ -128,6 +139,10 @@ export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { perMessageDeflate: true, }) as NodeWebSocketWithTransport; socket = nextSocket; + nextSocket.once("open", () => { + transport = nextSocket._socket ?? null; + resolveOpen(); + }); nextSocket.on("message", (data) => { const bytes = rawDataBytes(data); decodedBytes += bytes; @@ -136,11 +151,17 @@ export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { return nextSocket as unknown as globalThis.WebSocket; }, totals: () => ({ - wireBytes: socket?._socket?.bytesRead ?? 0, + wireBytes: transport?.bytesRead ?? socket?._socket?.bytesRead ?? 0, decodedBytes, messages, }), negotiatedExtensions: () => socket?.extensions ?? "", + awaitOpen: Effect.promise(() => opened).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.die(new Error("Timed out waiting for the WebSocket to open")), + }), + ), }; } @@ -175,3 +196,40 @@ export function countingWsRpcProtocolLayer(input: { export const makeCountingWsRpcClient = RpcClient.make(WsRpcGroup); export type CountingWsRpcClient = Effect.Success; + +export interface MeasuredWsClient { + readonly client: CountingWsRpcClient; + readonly recorder: WebSocketTransferRecorder; + /** Fork subscription consumers here so they stop before the socket closes. */ + readonly scope: Scope.Scope; + /** Closes the socket now. The enclosing scope closes it otherwise. */ + readonly close: Effect.Effect; +} + +/** + * Opens one WebSocket RPC client on a child of the current scope. Several + * clients can share one test scope and still disconnect independently, which + * a reconnect measurement needs. + */ +export const openMeasuredWsClient = Effect.fn("TransferBudget.openMeasuredWsClient")( + function* (input: { readonly url: string; readonly cookie: string }) { + const recorder = makeWebSocketTransferRecorder(); + const parent = yield* Effect.scope; + const scope = yield* Scope.fork(parent); + const protocol = yield* Layer.buildWithScope( + countingWsRpcProtocolLayer({ url: input.url, cookie: input.cookie, recorder }), + scope, + ); + const client = yield* makeCountingWsRpcClient.pipe( + Effect.provide(protocol), + Scope.provide(scope), + ); + yield* recorder.awaitOpen; + return { + client, + recorder, + scope, + close: Scope.close(scope, Exit.void), + } satisfies MeasuredWsClient; + }, +); diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c4e1c62ef50c0..b0173d5b4c8a5 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -22,6 +22,7 @@ import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; import { TextGeneration, type TextGenerationShape } from "../src/textGeneration/TextGeneration.ts"; @@ -231,6 +232,8 @@ export interface OrchestrationIntegrationHarness { interface MakeOrchestrationIntegrationHarnessOptions { readonly provider?: ProviderDriverKind; readonly realCodex?: boolean; + /** Tracer for every fiber the harness runtime runs, including reactors. */ + readonly tracer?: Tracer.Tracer; } export const makeOrchestrationIntegrationHarness = ( @@ -399,6 +402,9 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + options?.tracer ? Layer.succeed(Tracer.Tracer, options.tracer) : Layer.empty, + ), ); const runtime = ManagedRuntime.make(layer); diff --git a/apps/server/integration/SqlStatementCounter.integration.ts b/apps/server/integration/SqlStatementCounter.integration.ts new file mode 100644 index 0000000000000..e3a3aac3fba06 --- /dev/null +++ b/apps/server/integration/SqlStatementCounter.integration.ts @@ -0,0 +1,24 @@ +import * as Tracer from "effect/Tracer"; + +export interface SqlStatementCounter { + readonly tracer: Tracer.Tracer; + /** Statements executed so far. Read before and after a phase and subtract. */ + readonly count: () => number; +} + +/** + * Counts `sql.execute` spans, which the Effect SQL client opens once per + * statement. Spans behave exactly as with the native tracer. Install the same + * counter on every runtime under test, otherwise statements run by background + * reactors and statements run by request handlers land in different counters. + */ +export function makeSqlStatementCounter(): SqlStatementCounter { + let statements = 0; + const tracer = Tracer.make({ + span: (options) => { + if (options.name === "sql.execute") statements += 1; + return new Tracer.NativeSpan(options); + }, + }); + return { tracer, count: () => statements }; +} diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts index f773b5b8b8443..d82688d2863f2 100644 --- a/apps/server/integration/TransferBudgetReport.integration.ts +++ b/apps/server/integration/TransferBudgetReport.integration.ts @@ -12,10 +12,28 @@ import { TRANSFER_MEASURED_TOOLS, } from "./fixtures/transferBudget.ts"; +/** Catch-up delivered to a resubscribing client, and which path the server chose. */ +export interface WebSocketCatchUpMeasurement extends WebSocketTransferTotals { + readonly mode: "replay" | "snapshot"; +} + export interface TransferBudgetRun { readonly provider: ProviderDriverKind; readonly threadSnapshot: HttpTransferMeasurement; + /** One socket holding only the thread subscription. This is the capped measurement. */ readonly measuredTurnWebSocket: WebSocketTransferTotals; + readonly shellSnapshot: HttpTransferMeasurement; + /** One socket holding only the shell (sidebar) subscription during the same turn. */ + readonly measuredTurnShellWebSocket: WebSocketTransferTotals; + /** A second client: one socket holding both the thread and shell subscriptions. */ + readonly measuredTurnSecondClientWebSocket: WebSocketTransferTotals; + /** The second client resubscribes after the turn from the cursor it held before it. */ + readonly reconnectThread: WebSocketCatchUpMeasurement; + readonly reconnectShell: WebSocketCatchUpMeasurement; + /** `sql.execute` spans opened server-wide during the measured turn. */ + readonly measuredTurnSqlStatements: number; + /** `sql.execute` spans opened while serving both reconnect catch-ups. */ + readonly reconnectSqlStatements: number; } interface ProviderTransferBudget { @@ -47,6 +65,15 @@ function totalWireBytes(run: TransferBudgetRun): number { return run.threadSnapshot.wireBytes + run.measuredTurnWebSocket.wireBytes; } +/** Bytes the server wrote to every measured socket during the turn. */ +function serverEgressWireBytes(run: TransferBudgetRun): number { + return ( + run.measuredTurnWebSocket.wireBytes + + run.measuredTurnShellWebSocket.wireBytes + + run.measuredTurnSecondClientWebSocket.wireBytes + ); +} + function observedTransfer(run: TransferBudgetRun) { return { totalWireBytes: totalWireBytes(run), @@ -105,6 +132,33 @@ function row( return `| ${provider} | ${phase} | ${metric} | ${format(observed)} | ${format(maximum)} | ${status} |`; } +// Shell, second-client, reconnect, and SQL rows are reported without a cap. +// Shell delivery coalesces on a 50 ms window, so message counts and bytes move +// with scheduler timing between runs, and the reconnect and SQL figures follow +// the same batches. The rows exist so CI shows the numbers next to the capped +// thread measurement. +function infoRow( + provider: ProviderDriverKind, + phase: string, + metric: string, + observed: number, + format: (value: number) => string = formatBytes, +): string { + return `| ${provider} | ${phase} | ${metric} | ${format(observed)} | none | INFO |`; +} + +function webSocketRows( + provider: ProviderDriverKind, + phase: string, + totals: WebSocketTransferTotals, +): string[] { + return [ + infoRow(provider, phase, "WebSocket wire", totals.wireBytes), + infoRow(provider, phase, "WebSocket decoded", totals.decodedBytes), + infoRow(provider, phase, "WebSocket messages", totals.messages, String), + ]; +} + export function transferBudgetViolations(runs: ReadonlyArray): string[] { const violations: string[] = []; for (const run of runs) { @@ -146,6 +200,7 @@ export function formatTransferBudgetReport(runs: ReadonlyArray( + queue: Queue.Queue, + predicate: (value: A) => boolean, + waitDescription: string, +) { + return yield* Effect.gen(function* () { + const values: A[] = []; + while (true) { + const value = yield* Queue.take(queue); + values.push(value); + if (predicate(value)) return values; + } + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.die(new Error(`Timed out waiting for ${waitDescription}`)), + }), + ); +}); + +/** + * Resumes the thread subscription from a cursor on a measured client. The + * consumer runs in the client's scope, so closing the client stops it. Callers + * read items from the returned queue. + */ +export const subscribeThreadItems = Effect.fn("TransferBudget.subscribeThreadItems")(function* ( + measured: MeasuredWsClient, + afterSequence: number, +) { + const items = yield* Queue.unbounded(); + yield* measured.client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: TRANSFER_THREAD_ID, + afterSequence, + requestCompletionMarker: true, + }).pipe( + Stream.runForEach((item) => Queue.offer(items, item).pipe(Effect.asVoid)), + Scope.provide(measured.scope), + Effect.forkIn(measured.scope), + ); + return items; +}); + +/** Shell counterpart of subscribeThreadItems. */ +export const subscribeShellItems = Effect.fn("TransferBudget.subscribeShellItems")(function* ( + measured: MeasuredWsClient, + afterSequence: number, +) { + const items = yield* Queue.unbounded(); + yield* measured.client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence, + requestCompletionMarker: true, + }).pipe( + Stream.runForEach((item) => Queue.offer(items, item).pipe(Effect.asVoid)), + Scope.provide(measured.scope), + Effect.forkIn(measured.scope), + ); + return items; +}); + +/** Waits for the initial catch-up and reports whether it was a replay or a snapshot reset. */ +export const awaitSubscriptionSynchronized = Effect.fn( + "TransferBudget.awaitSubscriptionSynchronized", +)(function* ( + items: Queue.Queue, + waitDescription: string, +) { + const initial = yield* collectQueueUntil( + items, + (item) => item.kind === "synchronized", + waitDescription, + ); + return initial.some((item) => item.kind === "snapshot") + ? ("snapshot" as const) + : ("replay" as const); +}); + export { TRANSFER_HISTORY_TURN_COUNT, waitForTurnQuiesced }; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 10cf74240aedc..3729c10910a1c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -17,6 +17,8 @@ import { KeybindingRule, MessageId, ExternalLauncherCommandNotFoundError, + OrchestrationShellSnapshot, + type OrchestrationShellStreamItem, OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, type OrchestrationThreadActivity, @@ -58,11 +60,11 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import { ChildProcessSpawner } from "effect/unstable/process"; import { FetchHttpClient, @@ -82,26 +84,9 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationThreadDetailSnapshot), ); - -const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function* ( - queue: Queue.Queue, - predicate: (value: A) => boolean, - waitDescription: string, -) { - return yield* Effect.gen(function* () { - const values: A[] = []; - while (true) { - const value = yield* Queue.take(queue); - values.push(value); - if (predicate(value)) return values; - } - }).pipe( - Effect.timeoutOrElse({ - duration: "10 seconds", - orElse: () => Effect.die(new Error(`Timed out waiting for ${waitDescription}`)), - }), - ); -}); +const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationShellSnapshot), +); import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; @@ -171,16 +156,19 @@ import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; import { - countingWsRpcProtocolLayer, - makeCountingWsRpcClient, - makeWebSocketTransferRecorder, measureHttpGet, + openMeasuredWsClient, transferDelta, } from "../integration/NetworkTransferMeasurement.integration.ts"; +import { makeSqlStatementCounter } from "../integration/SqlStatementCounter.integration.ts"; import { + awaitSubscriptionSynchronized, + collectQueueUntil, expectedMeasuredAssistantText, queueMeasuredTransferTurn, seedTransferBudgetHistory, + subscribeShellItems, + subscribeThreadItems, TRANSFER_HISTORY_TURN_COUNT, TRANSFER_MEASURED_TURN_CREATED_AT, TRANSFER_MEASURED_TURN_INDEX, @@ -9313,9 +9301,12 @@ it.live( const runs = yield* Effect.forEach( providers, - (provider) => - Effect.acquireUseRelease( - makeOrchestrationIntegrationHarness({ provider }), + (provider) => { + // One counter for the orchestration runtime and the HTTP/WS handlers, + // so reactor writes and subscription reads land in the same total. + const sqlCounter = makeSqlStatementCounter(); + return Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness({ provider, tracer: sqlCounter.tracer }), (harness) => Effect.gen(function* () { yield* seedTransferBudgetHistory(harness, provider); @@ -9328,19 +9319,10 @@ it.live( const baseUrl = yield* getHttpServerUrl(); const cookie = yield* getAuthenticatedSessionCookieHeader(); - - const recorder = makeWebSocketTransferRecorder(); const wsUrl = baseUrl.replace(/^http:/, "ws:") + "/ws"; - const protocolLayer = countingWsRpcProtocolLayer({ - url: wsUrl, - cookie, - recorder, - }); return yield* Effect.scoped( Effect.gen(function* () { - const client = yield* makeCountingWsRpcClient; - const threadSnapshot = yield* measureHttpGet({ url: `${baseUrl}/api/orchestration/threads/${TRANSFER_THREAD_ID}`, headers: { cookie }, @@ -9354,29 +9336,78 @@ it.live( decodedThread.thread.messages.length, TRANSFER_HISTORY_TURN_COUNT * 2, ); + const shellSnapshot = yield* measureHttpGet({ + url: `${baseUrl}/api/orchestration/shell`, + headers: { cookie }, + }); + assert.equal(shellSnapshot.status, 200); + const decodedShell = yield* decodeTransferShellSnapshot( + Buffer.from(shellSnapshot.decodedBody).toString("utf8"), + ); + assert.equal(decodedShell.threads.length, 1); + + // Three sockets, the way real installs look: the capped + // thread-only client, a shell-only socket that isolates the + // sidebar cost, and a second device holding both. + const threadClient = yield* openMeasuredWsClient({ url: wsUrl, cookie }); + const shellClient = yield* openMeasuredWsClient({ url: wsUrl, cookie }); + const secondClient = yield* openMeasuredWsClient({ url: wsUrl, cookie }); + assert.include( + threadClient.recorder.negotiatedExtensions(), + "permessage-deflate", + ); - const threadItems = yield* Queue.unbounded(); - yield* client[ORCHESTRATION_WS_METHODS.subscribeThread]({ - threadId: TRANSFER_THREAD_ID, - afterSequence: decodedThread.snapshotSequence, - requestCompletionMarker: true, - }).pipe( - Stream.runForEach((item) => - Queue.offer(threadItems, item).pipe(Effect.asVoid), + const threadItems = yield* subscribeThreadItems( + threadClient, + decodedThread.snapshotSequence, + ); + const shellItems = yield* subscribeShellItems( + shellClient, + decodedShell.snapshotSequence, + ); + const secondThreadItems = yield* subscribeThreadItems( + secondClient, + decodedThread.snapshotSequence, + ); + const secondShellItems = yield* subscribeShellItems( + secondClient, + decodedShell.snapshotSequence, + ); + assert.equal( + yield* awaitSubscriptionSynchronized( + threadItems, + `${provider} thread subscription to synchronize`, ), - Effect.forkScoped, + "replay", ); - const initialThreadItems = yield* collectQueueUntil( - threadItems, - (item) => item.kind === "synchronized", - `${provider} thread subscription to synchronize`, + assert.equal( + yield* awaitSubscriptionSynchronized( + shellItems, + `${provider} shell subscription to synchronize`, + ), + "replay", + ); + assert.equal( + yield* awaitSubscriptionSynchronized( + secondThreadItems, + `${provider} second client thread subscription to synchronize`, + ), + "replay", + ); + assert.equal( + yield* awaitSubscriptionSynchronized( + secondShellItems, + `${provider} second client shell subscription to synchronize`, + ), + "replay", ); - assert.isFalse(initialThreadItems.some((item) => item.kind === "snapshot")); - assert.include(recorder.negotiatedExtensions(), "permessage-deflate"); yield* queueMeasuredTransferTurn(harness, provider); - const turnStartTotals = recorder.totals(); - yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + const turnStartTotals = threadClient.recorder.totals(); + const shellTurnStartTotals = shellClient.recorder.totals(); + const secondTurnStartTotals = secondClient.recorder.totals(); + const turnStartSqlStatements = sqlCounter.count(); + yield* threadClient.client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", commandId: CommandId.make(`transfer:${provider}:measured-turn`), threadId: TRANSFER_THREAD_ID, @@ -9392,26 +9423,95 @@ it.live( createdAt: TRANSFER_MEASURED_TURN_CREATED_AT, }); yield* waitForTurnQuiesced(harness, TRANSFER_MEASURED_TURN_INDEX + 1); - const finalThreadSequence = yield* harness.engine + const finalSequences = yield* harness.engine .readEvents(decodedThread.snapshotSequence, 10_000) .pipe( Stream.runFold( - () => decodedThread.snapshotSequence, - (sequence, event) => - event.aggregateId === TRANSFER_THREAD_ID && isThreadDetailEvent(event) - ? Math.max(sequence, event.sequence) - : sequence, + () => ({ + detail: decodedThread.snapshotSequence, + aggregate: decodedShell.snapshotSequence, + }), + (sequences, event) => + event.aggregateId !== TRANSFER_THREAD_ID + ? sequences + : { + detail: isThreadDetailEvent(event) + ? Math.max(sequences.detail, event.sequence) + : sequences.detail, + aggregate: Math.max(sequences.aggregate, event.sequence), + }, ), ); + const finalThreadSequence = finalSequences.detail; assert.isAbove(finalThreadSequence, decodedThread.snapshotSequence); + const reachedFinalThreadEvent = (item: OrchestrationThreadStreamItem) => + item.kind === "event" && item.event.sequence === finalThreadSequence; + // Shell items carry the sequence of the latest coalesced + // event for the thread, so the last one lands at or past + // the final thread event. + const reachedFinalShellEvent = (item: OrchestrationShellStreamItem) => + item.kind === "thread-upserted" && item.sequence >= finalSequences.aggregate; yield* collectQueueUntil( threadItems, - (item) => - item.kind === "event" && item.event.sequence === finalThreadSequence, + reachedFinalThreadEvent, `${provider} thread stream to reach sequence ${finalThreadSequence}`, ); - const measuredTurnWebSocket = transferDelta(turnStartTotals, recorder.totals()); + yield* collectQueueUntil( + secondThreadItems, + reachedFinalThreadEvent, + `${provider} second client thread stream to reach sequence ${finalThreadSequence}`, + ); + yield* collectQueueUntil( + shellItems, + reachedFinalShellEvent, + `${provider} shell stream to reach sequence ${finalSequences.aggregate}`, + ); + yield* collectQueueUntil( + secondShellItems, + reachedFinalShellEvent, + `${provider} second client shell stream to reach sequence ${finalSequences.aggregate}`, + ); + const measuredTurnWebSocket = transferDelta( + turnStartTotals, + threadClient.recorder.totals(), + ); + const measuredTurnShellWebSocket = transferDelta( + shellTurnStartTotals, + shellClient.recorder.totals(), + ); + const measuredTurnSecondClientWebSocket = transferDelta( + secondTurnStartTotals, + secondClient.recorder.totals(), + ); + const measuredTurnSqlStatements = sqlCounter.count() - turnStartSqlStatements; + + // The second device drops and comes back with the cursors it + // held before the turn, one subscription at a time so the + // catch-up bytes stay separable. + yield* secondClient.close; + const reconnectSqlStart = sqlCounter.count(); + const reconnected = yield* openMeasuredWsClient({ url: wsUrl, cookie }); + const reconnectStartTotals = reconnected.recorder.totals(); + const reconnectThreadItems = yield* subscribeThreadItems( + reconnected, + decodedThread.snapshotSequence, + ); + const reconnectThreadMode = yield* awaitSubscriptionSynchronized( + reconnectThreadItems, + `${provider} reconnected thread subscription to synchronize`, + ); + const reconnectThreadTotals = reconnected.recorder.totals(); + const reconnectShellItems = yield* subscribeShellItems( + reconnected, + decodedShell.snapshotSequence, + ); + const reconnectShellMode = yield* awaitSubscriptionSynchronized( + reconnectShellItems, + `${provider} reconnected shell subscription to synchronize`, + ); + const reconnectShellTotals = reconnected.recorder.totals(); + const reconnectSqlStatements = sqlCounter.count() - reconnectSqlStart; const finalThreadSnapshot = yield* harness.snapshotQuery .getThreadDetailSnapshot(TRANSFER_THREAD_ID) @@ -9436,12 +9536,29 @@ it.live( provider, threadSnapshot, measuredTurnWebSocket, + shellSnapshot, + measuredTurnShellWebSocket, + measuredTurnSecondClientWebSocket, + reconnectThread: { + mode: reconnectThreadMode, + ...transferDelta(reconnectStartTotals, reconnectThreadTotals), + }, + reconnectShell: { + mode: reconnectShellMode, + ...transferDelta(reconnectThreadTotals, reconnectShellTotals), + }, + measuredTurnSqlStatements, + reconnectSqlStatements, } satisfies TransferBudgetRun; - }).pipe(Effect.provide(protocolLayer)), + }), ); }), (harness) => harness.dispose, - ).pipe(Effect.provide(NodeHttpServerTestWithWsDeflate)), + ).pipe( + Effect.provideService(Tracer.Tracer, sqlCounter.tracer), + Effect.provide(NodeHttpServerTestWithWsDeflate), + ); + }, { concurrency: 1 }, ); From 0e1570bde5b64458449a45989f1b56d6fd651883 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 1 Sep 2026 19:26:07 -0700 Subject: [PATCH 062/136] fix(web): project default model works on the hosted app (#9142) Co-authored-by: Claude Code --- .../settings/IntegrationsSettings.tsx | 6 ++- .../settings/ProjectSettingsPanel.tsx | 37 +++++++++---- .../components/settings/SettingsPanels.tsx | 10 ++++ .../settings/SourceControlSettings.tsx | 4 +- .../settings/SourceControlWritingSettings.tsx | 3 ++ .../components/settings/settingsLayout.tsx | 52 +++++++++++++++++-- apps/web/src/hooks/useSettings.ts | 23 ++++++++ apps/web/src/hostedPairing.ts | 9 +++- 8 files changed, 124 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index d6c52a21e0a6c..2757866ecbbdc 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -408,6 +408,7 @@ function AgentBrowserAccessSetting() { return ( - {/* Server-authoritative, so it stays editable on every client and sits - outside the block covering the desktop-only defaults. */} + {/* Server-authoritative, so it stays editable on any client anchored to + a server; `serverScoped` covers the hosted app, which has none. It + sits outside the block covering the desktop-only defaults. */} {previewDefaultsDisabled ? ( {previewDefaults} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index dd9c899430b35..ea88d05066f3e 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -39,6 +39,7 @@ import { useComposerDraftStore } from "../../composerDraftStore"; import { isElectron } from "../../env"; import { useClientSettings, + useEnvironmentSettings, useUpdateClientSettings, usePrimarySettings, } from "../../hooks/useSettings"; @@ -69,7 +70,7 @@ import { import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { useProjects, useThreadShells } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; -import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; @@ -292,10 +293,20 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const representative = + group.memberProjects.find( + (member) => member.environmentId === group.environmentId && member.id === group.id, + ) ?? group.memberProjects[0]!; const settings = usePrimarySettings(); + // Provider instances and model options belong to the environment that runs + // the project's threads. The hosted app has no primary environment, so + // reading them from there would show "No providers available" everywhere. + const projectSettings = useEnvironmentSettings(representative.environmentId); + const serverProviders = + useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ?? + EMPTY_SERVER_PROVIDERS; const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const serverProviders = useAtomValue(primaryServerProvidersAtom); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); @@ -321,10 +332,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }, }); - const representative = - group.memberProjects.find( - (member) => member.environmentId === group.environmentId && member.id === group.id, - ) ?? group.memberProjects[0]!; const faviconPath = representative.faviconPath ?? null; const pickProjectFavicon = typeof window !== "undefined" && @@ -422,14 +429,22 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const instanceEntries = useMemo( () => sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + applyProviderInstanceSettings( + deriveProviderInstanceEntries(serverProviders), + projectSettings, + ), ), - [serverProviders, settings], + [serverProviders, projectSettings], ); const modelOptionsByInstance = useMemo( () => - getCustomModelOptionsByInstance(settings, serverProviders, resolvedInstanceId, resolvedModel), - [resolvedInstanceId, resolvedModel, serverProviders, settings], + getCustomModelOptionsByInstance( + projectSettings, + serverProviders, + resolvedInstanceId, + resolvedModel, + ), + [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], ); const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); const setDefaultModel = useCallback( @@ -864,7 +879,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { onPromptChange={() => {}} modelOptions={resolvedSelection.options ?? []} allowPromptInjectedEffort={false} - planModeEnabled={settings.planModeEnabled} + planModeEnabled={projectSettings.planModeEnabled} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 666ff4ff6ebcf..2bc748db35a50 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1827,6 +1827,7 @@ function LegacyFeaturesSection() { } /> {settings.sidebarAutoSettleAfterDays !== null ? ( @@ -2245,6 +2251,7 @@ export function GeneralSettingsPanel() { /> )} - {isPrimaryEnvironment ? : null} + {/* Its rows are serverScoped: without a primary they render inert with + an explanation, which beats disappearing. */} + ); } diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 7f3660c52b21b..33da931b9db5a 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -74,6 +74,7 @@ export function SourceControlWritingSettingsSection() { return ( (rowProps.id); + const primarySettingsAvailable = usePrimarySettingsAvailable(); + const unavailable = serverScoped && !primarySettingsAvailable; + const renderedReset = unavailable ? null : resetAction; + const renderedControl = + unavailable && control ? ( + + + } + > +
+ {control} +
+
+ + {PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE} + +
+ ) : ( + control + ); return (

{title}

- {resetAction} + {renderedReset}
{description ? ( @@ -200,13 +238,19 @@ export function SettingsRow({ ) : null} {status ?
{status}
: null} - {control ? ( + {renderedControl ? (
- {control} + {renderedControl}
) : null} - {children} + {unavailable && children ? ( +
+ {children} +
+ ) : ( + children + )} ); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 23221067b271d..a04c514fb4589 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -39,6 +39,8 @@ import { themeAllowsSidebarArtwork, } from "~/themePalette"; import * as Struct from "effect/Struct"; +import { toastManager } from "~/components/ui/toast"; +import { isHostedStaticApp } from "~/hostedPairing"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { type EnvironmentPresentation, @@ -316,6 +318,20 @@ export function usePrimarySettings( return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); } +export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE = + "This setting is saved on a server, and the hosted app is not anchored to one. Change it from the desktop app or from the server's own address."; + +/** + * Whether primary-scoped server settings have a server to live on. The + * hosted app connects to every environment as a remote, so it has no primary: + * `usePrimarySettings` reads schema defaults there and writes have nowhere + * to go. Desktop and server-served web always have one. + */ +export function usePrimarySettingsAvailable(): boolean { + const primaryEnvironment = usePrimaryEnvironment(); + return primaryEnvironment !== null || !isHostedStaticApp(); +} + /** * Whether an environment can hold every shared key right now. Gated on the * auto-settlement capability because it is the newest of the shared keys: a @@ -366,6 +382,13 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { environmentId, input: { patch: localPatch }, }); + } else { + // Dropping the write silently leaves the control looking saved. + toastManager.add({ + type: "warning", + title: "Setting not saved", + description: PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, + }); } if (Object.keys(sharedPatch).length > 0) { const targets = new Set(connectedEnvironmentIds); diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index 6a9815b4b336e..6c5ac58cb35d7 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -31,7 +31,7 @@ function originFromUrl(value: string): string | null { } } -export function isHostedStaticApp(url: URL = new URL(window.location.href)): boolean { +export function isHostedStaticApp(url?: URL): boolean { if (configuredBackendUrl()) { return false; } @@ -40,8 +40,13 @@ export function isHostedStaticApp(url: URL = new URL(window.location.href)): boo return true; } + // No window (tests, static render) means no origin to be hosted at. + if (url === undefined && typeof window === "undefined") { + return false; + } + const hostedOrigin = originFromUrl(configuredHostedAppUrl()); - return hostedOrigin !== null && url.origin === hostedOrigin; + return hostedOrigin !== null && (url ?? new URL(window.location.href)).origin === hostedOrigin; } export function readHostedPairingRequest(url: URL = new URL(window.location.href)) { From 8401f4d85887786fbb773d3fd41b8f07a77624b8 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 22:28:43 -0400 Subject: [PATCH 063/136] fix(web): darken neutral control surfaces (#9064) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/chat/ComposerBanner.tsx | 2 +- apps/web/src/components/chat/ComposerSurface.tsx | 4 ++-- apps/web/src/index.css | 12 ++++++------ apps/web/src/themePalette.test.ts | 14 ++++++++++++++ apps/web/src/themePalette.ts | 10 +++++----- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index d222972b4151c..c1de09e415dc3 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -11,7 +11,7 @@ export type ComposerBannerVariant = "default" | "error" | "info" | "success" | " const surfaceColors = cn( "[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--card))]", - "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,color-mix(in_srgb,var(--background)_96%,var(--color-white)))]", + "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--surface-raised))]", "[html[data-theme-id]_&]:[--chat-composer-attached-surface:var(--app-theme-surface-raised)]", ); diff --git a/apps/web/src/components/chat/ComposerSurface.tsx b/apps/web/src/components/chat/ComposerSurface.tsx index b4bad77401ddc..6b20a4b693e48 100644 --- a/apps/web/src/components/chat/ComposerSurface.tsx +++ b/apps/web/src/components/chat/ComposerSurface.tsx @@ -15,7 +15,7 @@ function Shell({ className={cn( "group/composer-surface relative isolate mx-auto w-full max-w-3xl", "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]", - "dark:[--chat-composer-glass-surface:color-mix(in_srgb,var(--background)_96%,var(--color-white))] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]", + "dark:[--chat-composer-glass-surface:var(--surface-raised)] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]", "[html[data-theme-id]_&]:[--chat-composer-glass-surface:var(--app-theme-surface-raised)] [html[data-theme-id]_&]:[--chat-composer-outline:var(--app-theme-toolbar-border)]", "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-highlight:color-mix(in_srgb,var(--app-theme-input)_12%,transparent)] dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-outline:color-mix(in_srgb,var(--app-theme-input)_30%,var(--background))]", "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-highlight:color-mix(in_srgb,#432d48_12%,transparent)] dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-outline:#241e28]", @@ -86,7 +86,7 @@ function ContextStrip({ className, ...props }: ComponentProps<"div">) { className={cn( "group/composer-context relative isolate mx-auto -mt-4 flex w-[calc(100%-2*var(--chat-composer-drawer-inset))] items-center gap-2 overflow-x-clip overflow-y-visible ps-1 pe-2 pt-5 pb-1", "before:absolute before:inset-0 before:-z-1 before:rounded-b-[16px] before:border before:border-(--chat-composer-outline) before:mask-[linear-gradient(to_bottom,transparent_0_1rem,black_1rem)] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)]", - "dark:before:border-white/7 dark:before:bg-[linear-gradient(to_bottom,transparent_0_1rem,rgb(0_0_0/18%)_1rem,transparent_calc(1rem+10px)),linear-gradient(rgb(255_255_255/2%),rgb(255_255_255/2%))] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]", + "dark:before:border-white/7 dark:before:bg-[linear-gradient(to_bottom,transparent_0_1rem,rgb(0_0_0/18%)_1rem,transparent_calc(1rem+10px)),linear-gradient(rgb(255_255_255/1%),rgb(255_255_255/1%))] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]", "group-has-data-[composer-banner-surface=attached]/composer-surface:before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] group-has-data-[composer-banner-surface=attached]/composer-surface:before:backdrop-blur-(--glass-blur) group-has-data-[composer-banner-surface=attached]/composer-surface:before:backdrop-saturate-(--glass-saturation)", "not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:backdrop-blur-(--glass-blur) not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:backdrop-saturate-(--glass-saturation)", className, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 22396d4f8aea1..91ed589336ad9 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -979,19 +979,19 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil @variant dark { color-scheme: dark; - /* Keep the workspace in the same neutral-black family as sidebar v2. - Surfaces lift from this base instead of starting from a milky gray. */ + /* Keep controls and floating surfaces close to the neutral-black canvas. + Borders and hover states provide separation without milky gray fills. */ --background: var(--color-neutral-950); - --surface-raised: var(--secondary); + --surface-raised: color-mix(in srgb, var(--background) 97%, var(--color-white)); --foreground: var(--color-neutral-100); --card: color-mix(in srgb, var(--background) 97%, var(--color-white)); --card-foreground: var(--color-neutral-100); - --popover: color-mix(in srgb, var(--background) 94%, var(--color-white)); + --popover: color-mix(in srgb, var(--background) 97%, var(--color-white)); --popover-foreground: var(--color-neutral-100); --primary: oklch(0.571 0.21 264); - --secondary: --alpha(var(--color-white) / 4%); + --secondary: --alpha(var(--color-white) / 3%); --secondary-foreground: var(--color-neutral-100); - --muted: --alpha(var(--color-white) / 4%); + --muted: --alpha(var(--color-white) / 3%); --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); --accent: --alpha(var(--color-white) / 4%); --accent-foreground: var(--color-neutral-100); diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 38e393608136f..a76885a862ee6 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -11,6 +11,7 @@ import { getThemePreferenceMode, isKnownThemePreference, getCustomThemes, + getStandardThemeColors, getStoredCustomThemeCollection, invalidateCustomThemes, installCustomTheme, @@ -134,6 +135,19 @@ describe("theme files", () => { expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error)); }); + it("keeps stock dark controls in the neutral-black surface hierarchy", () => { + expectThemeColors(getStandardThemeColors("dark"), { + canvas: "#0a0a0a", + surface: "#111111", + surfaceRaised: "#111111", + surfaceOverlay: "#111111", + toolbarControl: "#111111", + secondary: "#111111", + muted: "#111111", + accentSurface: "#141414", + }); + }); + it("derives readable, distinctive vivid palettes from exact seeds", () => { const seeds: ReadonlyArray<["light" | "dark", string, string]> = [ ["light", "#f4f9f2", "#1d8a4e"], diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index f7dd111705907..7e4b2b203ec43 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -402,12 +402,12 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = { toolbar: "#0a0a0a", toolbarForeground: "#f5f5f5", toolbarBorder: "#191919", - toolbarControl: "#191919", + toolbarControl: "#111111", toolbarControlForeground: "#f5f5f5", toolbarControlHover: "#141414", surface: "#111111", - surfaceRaised: "#141414", - surfaceOverlay: "#191919", + surfaceRaised: "#111111", + surfaceOverlay: "#111111", text: "#f5f5f5", textMuted: "#818181", border: "#191919", @@ -415,9 +415,9 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = { focus: "#346bf1", accent: "#346bf1", accentForeground: "#ffffff", - secondary: "#141414", + secondary: "#111111", secondaryForeground: "#f5f5f5", - muted: "#141414", + muted: "#111111", mutedForeground: "#818181", placeholder: "#818181", secondaryLabel: "#818181", From f2a914b8588bf3038472c14b129fbc5ddb3a2992 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 22:30:00 -0400 Subject: [PATCH 064/136] fix(web): preserve panel state across workspace refreshes (#8968) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/DiffPanel.tsx | 20 ++++-- .../components/diffs/AnnotatableCodeView.tsx | 5 +- .../src/components/files/FileBrowserPanel.tsx | 14 +++- .../files/fileTreePathReconciliation.test.ts | 31 +++++++++ .../files/fileTreePathReconciliation.ts | 32 ++++++++++ apps/web/src/lib/diffRendering.test.ts | 48 +++++++++++++- apps/web/src/lib/diffRendering.ts | 64 +++++++++++++++++++ 7 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/files/fileTreePathReconciliation.test.ts create mode 100644 apps/web/src/components/files/fileTreePathReconciliation.ts diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index a5cf9f783c7ed..0adc58b236c27 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,7 +30,8 @@ import { cn } from "~/lib/utils"; import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; import { useTheme } from "../hooks/useTheme"; import { - buildFileDiffRenderKey, + buildFileDiffContentVersion, + buildFileDiffIdentityKey, getDiffCollapseIconClassName, getDiffLineStat, getRenderablePatch, @@ -300,7 +301,7 @@ export default function DiffPanel({ const selectedGitSource = branchDiffPreview.data?.sources.find( (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), ); - const loadDiffFiles = useMemo(() => { + const currentLoadDiffFiles = useMemo(() => { const preview = branchDiffPreview.data; if (selectedTurnId !== null || !activeThread || !preview || !selectedGitSource) { return undefined; @@ -321,6 +322,13 @@ export default function DiffPanel({ selectedGitSource, selectedTurnId, ]); + const loadDiffFilesRef = useRef(currentLoadDiffFiles); + loadDiffFilesRef.current = currentLoadDiffFiles; + const loadDiffFiles = useCallback(async (fileDiff) => { + const loader = loadDiffFilesRef.current; + if (!loader) throw new Error("Diff file contents are unavailable for this selection."); + return loader(fileDiff); + }, []); const localBranchRefs = useEnvironmentQuery( selectedTurnId === null && selectedGitScope === "branch" && @@ -401,17 +409,19 @@ export default function DiffPanel({ () => renderableFiles.map((fileDiff) => ({ fileDiff, - fileKey: buildFileDiffRenderKey(fileDiff), + fileKey: buildFileDiffIdentityKey(fileDiff), + fileVersion: buildFileDiffContentVersion(fileDiff), })), [renderableFiles], ); const codeViewFiles = useMemo( () => - renderableFileEntries.map(({ fileDiff, fileKey }) => { + renderableFileEntries.map(({ fileDiff, fileKey, fileVersion }) => { return { fileDiff, filePath: resolveFileDiffPath(fileDiff), fileKey, + fileVersion, collapsed: collapsedDiffFileKeys.has(fileKey), }; }), @@ -952,7 +962,7 @@ export default function DiffPanel({ preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme as DiffThemeType, stickyHeaders: true, - ...(loadDiffFiles ? { loadDiffFiles } : {}), + ...(currentLoadDiffFiles ? { loadDiffFiles } : {}), }} /> diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index f0d989a7d5bba..f48e5b5eaf219 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -77,6 +77,7 @@ interface AnnotatableCodeViewProps { fileDiff: FileDiffMetadata; filePath: string; fileKey: string; + fileVersion: number; collapsed: boolean; }>; sectionId: string; @@ -125,7 +126,7 @@ export function AnnotatableCodeView({ const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]); const items = useMemo[]>( () => - files.map(({ fileDiff, filePath, fileKey, collapsed }) => { + files.map(({ fileDiff, filePath, fileKey, fileVersion, collapsed }) => { const persisted = reviewComments .filter( (comment) => @@ -153,7 +154,7 @@ export function AnnotatableCodeView({ annotations, collapsed, version: fnv1a32( - `${collapsed ? "1" : "0"}:${annotations + `${fileVersion}:${collapsed ? "1" : "0"}:${annotations .flatMap((annotation) => annotation.metadata.entries.map( (entry) => `${entry.id}:${entry.rangeLabel}:${entry.text}`, diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 767492ba137a4..5e4ce19335a12 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -22,6 +22,7 @@ import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; +import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; interface FileBrowserPanelProps { @@ -126,7 +127,7 @@ export default function FileBrowserPanel({ () => entries.filter((entry) => entry.kind === "directory").map(treePath), [entries], ); - const previousTreePathsRef = useRef([]); + const previousTreePathsRef = useRef(null); const syncingSelectionRef = useRef(false); const treeSelectionPathRef = useRef(null); const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); @@ -281,11 +282,18 @@ export default function FileBrowserPanel({ }); useEffect(() => { + if (entriesQuery.data === null) return; if (previousTreePathsRef.current === treePaths) return; entryKindsRef.current = entryKinds; + const previousTreePaths = previousTreePathsRef.current; previousTreePathsRef.current = treePaths; - model.resetPaths(treePaths); - }, [entryKinds, model, treePaths]); + if (previousTreePaths === null) { + model.resetPaths(treePaths); + return; + } + const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); + if (updates.length > 0) model.batch(updates); + }, [entriesQuery.data, entryKinds, model, treePaths]); useEffect(() => { if (!selectedPath) { diff --git a/apps/web/src/components/files/fileTreePathReconciliation.test.ts b/apps/web/src/components/files/fileTreePathReconciliation.test.ts new file mode 100644 index 0000000000000..ce7779796e7cd --- /dev/null +++ b/apps/web/src/components/files/fileTreePathReconciliation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; + +describe("buildFileTreePathUpdates", () => { + it("updates only paths that changed", () => { + expect( + buildFileTreePathUpdates( + ["src/", "src/kept.ts", "src/removed.ts"], + ["src/", "src/kept.ts", "src/added.ts"], + ), + ).toEqual([ + { type: "remove", path: "src/removed.ts" }, + { type: "add", path: "src/added.ts" }, + ]); + }); + + it("removes a missing subtree with one recursive update", () => { + expect( + buildFileTreePathUpdates( + ["src/", "src/feature/", "src/feature/index.ts", "src/kept.ts"], + ["src/", "src/kept.ts"], + ), + ).toEqual([{ type: "remove", path: "src/feature/", recursive: true }]); + }); + + it("does nothing when a refresh returns the same tree", () => { + const paths = ["src/", "src/index.ts"]; + expect(buildFileTreePathUpdates(paths, [...paths])).toEqual([]); + }); +}); diff --git a/apps/web/src/components/files/fileTreePathReconciliation.ts b/apps/web/src/components/files/fileTreePathReconciliation.ts new file mode 100644 index 0000000000000..5bcccb2cd2a14 --- /dev/null +++ b/apps/web/src/components/files/fileTreePathReconciliation.ts @@ -0,0 +1,32 @@ +import type { FileTreeBatchOperation } from "@pierre/trees"; + +function pathDepth(path: string): number { + return path.split("/").filter(Boolean).length; +} + +export function buildFileTreePathUpdates( + previousPaths: readonly string[], + nextPaths: readonly string[], +): FileTreeBatchOperation[] { + const previous = new Set(previousPaths); + const next = new Set(nextPaths); + const removedDirectoryRoots: string[] = []; + const updates: FileTreeBatchOperation[] = []; + + const removedPaths = previousPaths + .filter((path) => !next.has(path)) + .toSorted((left, right) => pathDepth(left) - pathDepth(right)); + for (const path of removedPaths) { + if (removedDirectoryRoots.some((directory) => path.startsWith(directory))) continue; + const recursive = path.endsWith("/"); + updates.push({ type: "remove", path, ...(recursive ? { recursive: true } : {}) }); + if (recursive) removedDirectoryRoots.push(path); + } + + const addedPaths = nextPaths + .filter((path) => !previous.has(path)) + .toSorted((left, right) => pathDepth(left) - pathDepth(right)); + for (const path of addedPaths) updates.push({ type: "add", path }); + + return updates; +} diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index 9ffaaf27d68a0..6ba83cc7a9a48 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + buildFileDiffContentVersion, + buildFileDiffIdentityKey, buildFileDiffRenderKey, buildPatchCacheKey, getDiffLineStat, @@ -82,8 +84,8 @@ describe("getRenderablePatch", () => { }); }); -describe("buildFileDiffRenderKey", () => { - it("keeps file identity stable when Pierre hydrates a partial diff", () => { +describe("diff file reconciliation", () => { + it("keeps Pierre's render key stable when a partial diff hydrates", () => { const patch = [ "diff --git a/example.ts b/example.ts", "--- a/example.ts", @@ -104,6 +106,48 @@ describe("buildFileDiffRenderKey", () => { expect(buildFileDiffRenderKey(file)).toBe(key); }); + + it("keeps identities stable and versions local to the changed file", () => { + const patch = (secondLine: string) => + [ + "diff --git a/unchanged.ts b/unchanged.ts", + "--- a/unchanged.ts", + "+++ b/unchanged.ts", + "@@ -1 +1 @@", + "-before", + "+after", + "diff --git a/changed.ts b/changed.ts", + "--- a/changed.ts", + "+++ b/changed.ts", + "@@ -1 +1 @@", + "-old", + `+${secondLine}`, + ].join("\n"); + const before = getRenderablePatch(patch("new"), "before"); + const after = getRenderablePatch(patch("newer"), "after"); + expect(before?.kind).toBe("files"); + expect(after?.kind).toBe("files"); + if (before?.kind !== "files" || after?.kind !== "files") return; + + const [beforeUnchanged, beforeChanged] = before.files; + const [afterUnchanged, afterChanged] = after.files; + expect(beforeUnchanged).toBeDefined(); + expect(beforeChanged).toBeDefined(); + expect(afterUnchanged).toBeDefined(); + expect(afterChanged).toBeDefined(); + if (!beforeUnchanged || !beforeChanged || !afterUnchanged || !afterChanged) return; + + expect(buildFileDiffIdentityKey(afterUnchanged)).toBe( + buildFileDiffIdentityKey(beforeUnchanged), + ); + expect(buildFileDiffIdentityKey(afterChanged)).toBe(buildFileDiffIdentityKey(beforeChanged)); + expect(buildFileDiffContentVersion(afterUnchanged)).toBe( + buildFileDiffContentVersion(beforeUnchanged), + ); + expect(buildFileDiffContentVersion(afterChanged)).not.toBe( + buildFileDiffContentVersion(beforeChanged), + ); + }); }); describe("getDiffLineStat", () => { diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index c1556981743d1..7d031e537c658 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -163,6 +163,10 @@ export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string return raw; } +export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string { + return `${resolveFileDiffPreviousPath(fileDiff)}\u0000${resolveFileDiffPath(fileDiff)}`; +} + export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { const cacheKey = fileDiff.cacheKey; if (!cacheKey) return `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; @@ -170,6 +174,66 @@ export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { return cacheKey.endsWith(":hydrated") ? cacheKey.slice(0, -":hydrated".length) : cacheKey; } +function hashFileDiffPart(hash: number, value: string | number | boolean | undefined): number { + const serialized = value === undefined ? "undefined" : String(value); + const withLength = fnv1a32(`${typeof value}:${serialized.length}:`, hash); + return fnv1a32(serialized, withLength); +} + +/** + * Content-only version for CodeView reconciliation. Pierre's cache key includes + * the whole patch, so using it here would repaint every file when one changes. + */ +export function buildFileDiffContentVersion(fileDiff: FileDiffMetadata): number { + let hash = FNV_OFFSET_BASIS_32; + const append = (value: string | number | boolean | undefined) => { + hash = hashFileDiffPart(hash, value); + }; + + append(fileDiff.name); + append(fileDiff.prevName); + append(fileDiff.lang); + append(fileDiff.newObjectId); + append(fileDiff.prevObjectId); + append(fileDiff.mode); + append(fileDiff.prevMode); + append(fileDiff.type); + append(fileDiff.isPartial); + append(fileDiff.splitLineCount); + append(fileDiff.unifiedLineCount); + + for (const line of fileDiff.additionLines) append(line); + for (const line of fileDiff.deletionLines) append(line); + for (const hunk of fileDiff.hunks) { + append(hunk.collapsedBefore); + append(hunk.additionStart); + append(hunk.additionCount); + append(hunk.additionLines); + append(hunk.additionLineIndex); + append(hunk.deletionStart); + append(hunk.deletionCount); + append(hunk.deletionLines); + append(hunk.deletionLineIndex); + append(hunk.hunkContext); + append(hunk.hunkSpecs); + append(hunk.splitLineStart); + append(hunk.splitLineCount); + append(hunk.unifiedLineStart); + append(hunk.unifiedLineCount); + append(hunk.noEOFCRAdditions); + append(hunk.noEOFCRDeletions); + for (const content of hunk.hunkContent) { + append(content.type); + append(content.additionLineIndex); + append(content.deletionLineIndex); + append(content.type === "change" ? content.additions : content.lines); + append(content.type === "change" ? content.deletions : undefined); + } + } + + return hash; +} + export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string { switch (fileDiff.type) { case "new": From f46a709ee487e1e6e04423fcb7032a26eb103129 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 1 Sep 2026 19:31:55 -0700 Subject: [PATCH 065/136] feat(files): open markdown, HTML, and PDF files outside the workspace (#9140) Co-authored-by: Claude Code --- .../features/files/ThreadFilesRouteScreen.tsx | 11 +++- .../src/features/files/filePath.test.ts | 15 ++++- apps/mobile/src/features/files/filePath.ts | 9 ++- .../features/files/workspaceFileAssetUrl.ts | 14 ++-- .../src/features/threads/ThreadFeed.tsx | 38 ++++++++++- apps/server/src/assets/AssetAccess.test.ts | 10 +-- apps/server/src/assets/AssetAccess.ts | 6 +- apps/server/src/http.test.ts | 16 ++--- apps/server/src/http.ts | 9 ++- .../src/workspace/WorkspaceFileSystem.test.ts | 66 +++++++++++++++++++ .../src/workspace/WorkspaceFileSystem.ts | 54 +++++++++++++-- apps/web/src/browser/openFileInPreview.ts | 10 ++- apps/web/src/components/ChatMarkdown.tsx | 56 +++++++++------- apps/web/src/components/RightPanelTabs.tsx | 4 +- .../src/components/files/FilePreviewPanel.tsx | 34 ++++++---- .../web/src/components/files/filePath.test.ts | 20 +++++- apps/web/src/components/files/filePath.ts | 17 ++++- apps/web/src/markdown-links.test.ts | 16 +++++ apps/web/src/markdown-links.ts | 20 ++++-- apps/web/src/rightPanelStore.ts | 1 + apps/web/src/terminal-links.ts | 2 +- docs/internals/environment-auth.md | 23 +++++-- docs/user/composer.md | 9 +++ packages/contracts/src/assets.ts | 5 +- packages/contracts/src/project.ts | 2 + packages/shared/src/filePreview.ts | 16 +++++ 26 files changed, 394 insertions(+), 89 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 95177ed3e92ab..c682436cad832 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -60,6 +60,7 @@ import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, + isAbsolutePath, isMarkdownPreviewFile, isSvgImagePreviewFile, isVideoPreviewFile, @@ -782,8 +783,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); } - const parentDir = relativePath.split("/").slice(0, -1).join("/"); - const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" · "); + const parentDir = relativePath.slice( + 0, + Math.max(relativePath.lastIndexOf("/"), relativePath.lastIndexOf("\\"), 0), + ); + // A host file outside the workspace is not under the project name. + const headerSubtitle = isAbsolutePath(relativePath) + ? parentDir + : [projectName, parentDir].filter(Boolean).join(" · "); return ( diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index 376068c211575..cd453b1d2c989 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it } from "vite-plus/test"; -import { isSvgImagePreviewFile, resolveWorkspaceRelativeFilePath } from "./filePath"; +import { + fileRoutePathSegments, + isSvgImagePreviewFile, + resolveWorkspaceRelativeFilePath, +} from "./filePath"; + +describe("fileRoutePathSegments", () => { + it("round-trips workspace-relative and host paths through the route", () => { + expect(fileRoutePathSegments("src/main.ts")).toEqual(["src", "main.ts"]); + expect(fileRoutePathSegments("/tmp/t3-cleanup/report.md").join("/")).toBe( + "/tmp/t3-cleanup/report.md", + ); + }); +}); describe("resolveWorkspaceRelativeFilePath", () => { it("keeps normalized workspace-relative paths", () => { diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 76672ec5658d8..f2d377de126f4 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -10,10 +10,17 @@ function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } -function isAbsolutePath(value: string): boolean { +/** A file route holding an absolute path shows a host file outside the workspace. */ +export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } +/** Route segments that `normalizeRoutePath` joins back into the same path, root included. */ +export function fileRoutePathSegments(path: string): string[] { + const segments = path.split("/").filter((segment) => segment.length > 0); + return path.startsWith("/") ? ["", ...segments] : segments; +} + function isWindowsPathStyle(value: string): boolean { return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value); } diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts index eb2ba93b4979f..8ea903f831ba2 100644 --- a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -2,7 +2,7 @@ import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts" import { useMemo } from "react"; import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; -import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; +import { isAbsolutePath, isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; export function useWorkspaceFileAssetUrlState(props: { readonly cwd: string | null; @@ -18,16 +18,22 @@ export function useWorkspaceFileAssetUrlState(props: { [props.cwd, props.relativePath], ); + // Videos stream from an exact-file URL, and so does anything outside the + // workspace, where no workspace-scoped URL can exist. + const relativePath = props.relativePath; const resource = useMemo( () => - absolutePath !== null && props.threadId !== null + absolutePath !== null && relativePath !== null && props.threadId !== null ? { - _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file", + _tag: + isVideoPreviewFile(absolutePath) || isAbsolutePath(relativePath) + ? "media-file" + : "workspace-file", threadId: props.threadId, path: absolutePath, } : null, - [absolutePath, props.threadId], + [absolutePath, relativePath, props.threadId], ); const state = useAssetUrlState(props.environmentId, resource); const refresh = useRefreshAssetUrl(props.environmentId, resource); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 3cbf02efa2ba9..5c2c4faba153c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -166,7 +166,12 @@ import { import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; import * as Option from "effect/Option"; -import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; +import { + basename, + fileRoutePathSegments, + isAbsolutePath, + resolveWorkspaceRelativeFilePath, +} from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { @@ -2068,7 +2073,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), - path: relativePath.split("/").filter((segment) => segment.length > 0), + path: fileRoutePathSegments(relativePath), ...(presentation.line ? { line: String(presentation.line) } : {}), }); return; @@ -2090,6 +2095,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return; } + // A host file outside the workspace, such as a report an agent wrote to + // a temp directory, opens read-only in the file screen. + if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { + void Haptics.selectionAsync(); + if (isPdfFile({ name: presentation.path })) { + setExpandedFile( + (current) => + current ?? { + kind: "pdf", + name: basename(presentation.path), + environmentId: props.environmentId, + resource: { + _tag: "media-file", + threadId: props.threadId, + path: presentation.path, + }, + }, + ); + return; + } + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: fileRoutePathSegments(presentation.path), + ...(presentation.line ? { line: String(presentation.line) } : {}), + }); + return; + } + if (presentation.kind !== "file" && presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { setExpandedFile( diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 4a47a17fabc3d..8d7c696cd6255 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -44,7 +44,7 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { - it.effect("issues exact URLs for images and videos outside the workspace", () => + it.effect("issues exact URLs for media and browser documents outside the workspace", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -54,6 +54,8 @@ describe("AssetAccess", () => { ["screenshot.png", "image/png"], ["recording.mp4", "video/mp4"], ["recording.webm", "video/webm"], + ["report.html", "text/html"], + ["report.pdf", "application/pdf"], ] as const) { const filePath = path.join(outside, name); yield* fs.writeFileString(filePath, "media"); @@ -104,12 +106,12 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("rejects non-media files, disguised targets, and directories", () => + it.effect("rejects non-previewable files, disguised targets, and directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" }); - for (const name of ["report.html", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { + for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { const filePath = path.join(root, name); yield* fs.writeFileString(filePath, "not media"); const error = yield* issueAssetUrl({ @@ -118,7 +120,7 @@ describe("AssetAccess", () => { expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); } const disguisedPath = path.join(root, "disguised.png"); - yield* fs.symlink(path.join(root, "report.html"), disguisedPath); + yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath); const disguisedError = yield* issueAssetUrl({ resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath }, }).pipe(Effect.flip); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 05801acca88f3..ad7cb273cf073 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -14,9 +14,9 @@ import { AssetWorkspaceRootNormalizationError, } from "@t3tools/contracts"; import { + hostPreviewMimeTypeFromExtension, isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, - mediaMimeTypeFromExtension, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; @@ -246,7 +246,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i if (!canonicalFile) { return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); } - if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) { + if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } const identity = yield* openMediaFile(canonicalFile).pipe( @@ -598,7 +598,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( Effect.orElseSucceed(() => null), ); if (canonicalFile !== claims.filePath) return null; - const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile)); + const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)); if (!mimeType) return null; const file = yield* openMediaFile(canonicalFile, claims).pipe( Effect.tapError((cause) => diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 9d54adef8eda7..6b09408566596 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -324,15 +324,13 @@ describe("assetResponseHeaders", () => { "X-Content-Type-Options": "nosniff", }); }); - it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => { - expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty( - "Content-Type", - "text/html; charset=utf-8", - ); - expect(assetResponseHeaders("/workspace/PAGE.HTM")).toHaveProperty( - "Content-Type", - "text/html; charset=utf-8", - ); + it("serves HTML assets as utf-8 inside a sandboxed origin", () => { + for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) { + expect(assetResponseHeaders(path)).toMatchObject({ + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals", + }); + } }); it("downloads uploaded documents without executing their content", () => { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 8b7c2ad3a61b6..4d5865335a392 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -51,6 +51,10 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; +// HTML previews are agent output, not the app. The sandbox gives the document an +// opaque origin: scripts run, but same-origin cookies, storage, and API calls are +// out of reach. Relative sibling assets still load through their signed URLs. +const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-modals"; // Types a browser may render as a document if a proxy strips the disposition // header. Downloads of these fall back to octet-stream. @@ -105,7 +109,10 @@ export function assetResponseHeaders( : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType) ? { "Content-Type": inlineVideoMimeType } : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") - ? { "Content-Type": "text/html; charset=utf-8" } + ? { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY, + } : {}), ...(!options?.download && lowerPath.endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993d0..17c7f0bb050ce 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO. +import * as NodeChildProcess from "node:child_process"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -73,6 +76,53 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("reads host files outside the workspace root by absolute path", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "cleanup-report.md", "# Report\n"); + const absolutePath = path.join(outsideDir, "cleanup-report.md"); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: absolutePath, + }); + + expect(result).toEqual({ + relativePath: absolutePath, + contents: "# Report\n", + byteLength: 9, + truncated: false, + }); + }), + ); + + it.effect("rejects a FIFO without blocking on open", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const fifoPath = path.join(outsideDir, "pipe"); + yield* Effect.promise( + () => + new Promise((resolve, reject) => + NodeChildProcess.execFile("mkfifo", [fifoPath], (error) => + error ? reject(error) : resolve(), + ), + ), + ); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: fifoPath }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); + }), + ); + it.effect("rejects reads outside the workspace root", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -212,6 +262,22 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("rejects writes by absolute path", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const absolutePath = path.join(outsideDir, "cleanup-report.md"); + + const error = yield* workspaceFileSystem + .writeFile({ cwd, relativePath: absolutePath, contents: "# Edited\n" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }), + ); + it.effect("invalidates workspace entry search cache after writes", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb3904..055ab51feaeb9 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -3,10 +3,13 @@ * WorkspaceFileSystem - Effect service contract for workspace file mutations. * * Owns workspace-root-relative file read/write operations and their associated - * safety checks and cache invalidation hooks. + * safety checks and cache invalidation hooks. Reads also accept absolute host + * paths so clients can show files an agent left outside the workspace; writes + * never leave the root. * * @module WorkspaceFileSystem */ +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import type { @@ -104,7 +107,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; export class WorkspaceFileSystem extends Context.Service< WorkspaceFileSystem, { - /** Read a UTF-8 text file relative to the workspace root. */ + /** + * Read a UTF-8 text file relative to the workspace root, or any host file by + * absolute path. + */ readonly readFile: ( input: ProjectReadFileInput, ) => Effect.Effect< @@ -132,9 +138,31 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( - "WorkspaceFileSystem.readFile", - )(function* (input) { + /** + * Resolves the file a read targets. Workspace-relative paths must stay inside the + * root, symlinks included. An absolute path reads a host file in place, such as a + * report an agent wrote to a temp directory; it gets no root check. + */ + const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* ( + input: ProjectReadFileInput, + ) { + const requestedPath = input.relativePath.trim(); + if (path.isAbsolute(requestedPath)) { + const realTargetPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(requestedPath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: requestedPath, + operationPath: requestedPath, + operation: "realpath-target", + cause, + }), + }); + return { relativePath: requestedPath, realTargetPath }; + } + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ workspaceRoot: input.cwd, relativePath: input.relativePath, @@ -177,10 +205,24 @@ export const make = Effect.gen(function* () { resolvedPath: realTargetPath, }); } + return { relativePath: target.relativePath, realTargetPath }; + }); + + const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( + "WorkspaceFileSystem.readFile", + )(function* (input) { + const target = yield* resolveReadTarget(input); + const realTargetPath = target.realTargetPath; return yield* Effect.acquireUseRelease( Effect.tryPromise({ - try: () => NodeFSP.open(realTargetPath, "r"), + // Non-blocking so a FIFO cannot hang the open; the stat below rejects + // it. Regular files ignore the flag. Windows lacks it. + try: () => + NodeFSP.open( + realTargetPath, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0), + ), catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index b89b87c928981..4e540a9a0963d 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -6,6 +6,7 @@ import type { PreviewSessionSnapshot, ScopedThreadRef, } from "@t3tools/contracts"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { type AtomCommandResult, mapAtomCommandResult, @@ -52,9 +53,14 @@ export async function openUrlInPreview(input: { }); } +/** + * Opens a browser document in the integrated browser. Inside the workspace the + * page may load sibling assets; a file outside it is served on its own. + */ export async function openFileInPreview(input: { readonly threadRef: ScopedThreadRef; readonly filePath: string; + readonly workspaceRoot: string | undefined; readonly httpBaseUrl: string; readonly createAssetUrl: (input: { readonly environmentId: EnvironmentId; @@ -71,11 +77,13 @@ export async function openFileInPreview(input: { ), ); } + const insideWorkspace = + mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined; const assetResult = await input.createAssetUrl({ environmentId: input.threadRef.environmentId, input: { resource: { - _tag: "workspace-file", + _tag: insideWorkspace ? "workspace-file" : "media-file", threadId: input.threadRef.threadId, path: input.filePath, }, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6f7327b3ab3e4..19496468c53c8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -164,7 +164,7 @@ import { } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; -import { resolvePathLinkTarget } from "../terminal-links"; +import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, @@ -188,6 +188,8 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; /** Append a prompt that invokes a newly created artifact-template skill. */ onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; + /** Directory that anchors relative links and images; defaults to `cwd`. Set + to the file's own directory when rendering a markdown file. */ imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable; @@ -1017,14 +1019,16 @@ interface MarkdownFileLinkProps { targetPath: string; iconPath: string; displayPath: string; - workspaceRelativePath: string | null; + /** What the files panel opens: workspace-relative inside the workspace, the + absolute host path outside it, null when the panel cannot show the file. */ + panelPath: string | null; line?: number | undefined; label: string; copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen?: ((targetPath: string) => Promise>) | undefined; - onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; + onOpenInPanel: (panelPath: string, line: number | undefined) => void; openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; onOpenMedia?: (() => void) | undefined; @@ -1603,7 +1607,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ targetPath, iconPath, displayPath, - workspaceRelativePath, + panelPath, line, label, copyMarkdown, @@ -1657,8 +1661,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [onOpen, targetPath]); const handleOpenInFilePreview = useCallback(() => { - if (threadRef && workspaceRelativePath) { - onOpenInPanel(workspaceRelativePath, line); + if (threadRef && panelPath) { + onOpenInPanel(panelPath, line); return; } if (onOpenMedia) { @@ -1666,7 +1670,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } handleOpenInEditor(); - }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]); + }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, panelPath, threadRef]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1867,7 +1871,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const canOpenInEditor = onOpen !== undefined; const canOpenInBrowser = onOpenInBrowser !== undefined; - const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath); + const canOpenInPanel = threadRef !== undefined && Boolean(panelPath); const hasPrimaryAction = hasMarkdownFilePrimaryAction({ canOpenInEditor, canOpenInBrowser, @@ -1954,7 +1958,7 @@ function areMarkdownFileLinkPropsEqual( previous.targetPath === next.targetPath && previous.iconPath === next.iconPath && previous.displayPath === next.displayPath && - previous.workspaceRelativePath === next.workspaceRelativePath && + previous.panelPath === next.panelPath && previous.line === next.line && previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && @@ -2098,24 +2102,24 @@ function ChatMarkdown({ for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; - const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); + const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd); if (meta) { metaByHref.set(normalizedHref, meta); } } return metaByHref; - }, [cwd, text]); + }, [cwd, imageBaseDir, text]); const inlineCodeFileLinkMetaByText = useMemo(() => { const metaByText = new Map(); for (const span of extractInlineCodeSpans(text)) { if (metaByText.has(span)) continue; - const meta = resolveInlineCodeFileLinkMeta(span, cwd); + const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); if (meta) { metaByText.set(span, meta); } } return metaByText; - }, [cwd, text]); + }, [cwd, imageBaseDir, text]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [ ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), @@ -2223,12 +2227,13 @@ function ChatMarkdown({ return openFileInPreview({ threadRef, filePath: path, + workspaceRoot: cwd, httpBaseUrl: preparedConnection.value.httpBaseUrl, createAssetUrl, openPreview, }); }, - [createAssetUrl, openPreview, preparedConnection, threadRef], + [createAssetUrl, cwd, openPreview, preparedConnection, threadRef], ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { @@ -2251,23 +2256,23 @@ function ChatMarkdown({ [cwd, environmentId, searchProjectEntries], ); // A bare filename resolves to the workspace root, which is rarely where the - // file is, so ask the index before opening. + // file is, so ask the index before opening. Absolute host paths open as-is. const openFileInPanel = useCallback( - (workspaceRelativePath: string, line: number | undefined) => { + (panelPath: string, line: number | undefined) => { if (!threadRef) return; // Claimed on every open so a synchronous one supersedes a lookup already // in flight. const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); - if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { - openAt(workspaceRelativePath); + if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) { + openAt(panelPath); return; } void (async () => { - const match = await findWorkspaceBasenameMatch(workspaceRelativePath); + const match = await findWorkspaceBasenameMatch(panelPath); if (!isLatestLookup()) return; - openAt(match ?? workspaceRelativePath); + openAt(match ?? panelPath); })(); }, [cwd, findWorkspaceBasenameMatch, threadRef], @@ -2310,6 +2315,11 @@ function ChatMarkdown({ mediaMimeTypeFromExtension( fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), ) !== null; + // Media outside the workspace keeps the expanded preview; other host + // files (a report in a temp dir) open read-only in the files panel. + const panelPath = + fileLinkMeta.workspaceRelativePath ?? + (!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null); return ( & { threadRef: ScopedThreadRef; + readOnly: boolean; }) { const saveCoordinator = useFileSaveCoordinator({ environmentId, @@ -819,15 +821,19 @@ function RenderedMarkdownSurface({ cwd={cwd} relativePath={relativePath} threadRef={threadRef} - onTaskListChange={({ markerOffset, checked }) => { - const currentContents = - getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? - contents; - const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); - if (nextContents === currentContents) return; - setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); - saveCoordinator.change(nextContents); - }} + onTaskListChange={ + readOnly + ? undefined + : ({ markerOffset, checked }) => { + const currentContents = + getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? + contents; + const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); + if (nextContents === currentContents) return; + setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); + saveCoordinator.change(nextContents); + } + } /> ); @@ -872,6 +878,8 @@ export default function FilePreviewPanel({ const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); const isMedia = isImage || isVideo; + // A file outside the workspace (an absolute path) is shown, never edited. + const isHostFile = relativePath !== null && isAbsolutePath(relativePath); const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); // Reading markdown rendered is a preference, not a property of one file. Keeping @@ -938,6 +946,7 @@ export default function FilePreviewPanel({ const result = await openFileInPreview({ threadRef, filePath: absolutePath, + workspaceRoot: cwd, httpBaseUrl: environmentHttpBaseUrl, createAssetUrl, openPreview, @@ -954,7 +963,7 @@ export default function FilePreviewPanel({ }), ); })(); - }, [absolutePath, createAssetUrl, environmentHttpBaseUrl, openPreview, threadRef]); + }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]); return (
@@ -1130,9 +1139,10 @@ export default function FilePreviewPanel({ relativePath={relativePath} threadRef={threadRef} contents={file.data.contents} + readOnly={isHostFile} onPendingChange={onPendingChange} /> - ) : file.data.truncated ? ( + ) : file.data.truncated || isHostFile ? ( { }); it("normalizes repeated separators", () => { - expect(fileBreadcrumbs("workspace", "/src//index.ts").map((crumb) => crumb.label)).toEqual([ + expect(fileBreadcrumbs("workspace", "src//index.ts").map((crumb) => crumb.label)).toEqual([ "workspace", "src", "index.ts", ]); }); + + it("starts host paths outside the workspace at the filesystem root", () => { + expect(fileBreadcrumbs("t3code", "/tmp/t3-cleanup/report.md")).toEqual([ + { label: "tmp", path: "/tmp", kind: "directory" }, + { label: "t3-cleanup", path: "/tmp/t3-cleanup", kind: "directory" }, + { label: "report.md", path: "/tmp/t3-cleanup/report.md", kind: "file" }, + ]); + expect(fileBreadcrumbs("t3code", "C:\\Temp\\report.md")).toEqual([ + { label: "C:", path: "C:", kind: "directory" }, + { label: "Temp", path: "C:\\Temp", kind: "directory" }, + { label: "report.md", path: "C:\\Temp\\report.md", kind: "file" }, + ]); + expect(fileBreadcrumbs("t3code", "\\\\server\\share\\report.md").map((c) => c.path)).toEqual([ + "\\\\server", + "\\\\server\\share", + "\\\\server\\share\\report.md", + ]); + }); }); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index 66ede58119b89..0598239cf7df8 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -1,16 +1,27 @@ +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; + +import { isAbsolutePath } from "~/terminal-links"; + export interface FileBreadcrumb { label: string; path: string; kind: "project" | "directory" | "file"; } +/** + * Crumbs for a workspace-relative path start at the project. An absolute host + * path is outside the workspace, so its crumbs start at the filesystem root. + */ export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] { - const parts = relativePath.split("/").filter(Boolean); + const hostPath = isAbsolutePath(relativePath); + const separator = isWindowsAbsolutePath(relativePath) ? "\\" : "/"; + const parts = relativePath.split(/[\\/]/).filter(Boolean); + const root = relativePath.startsWith("\\\\") ? "\\\\" : hostPath && separator === "/" ? "/" : ""; return [ - { label: projectName, path: "", kind: "project" }, + ...(hostPath ? [] : [{ label: projectName, path: "", kind: "project" as const }]), ...parts.map((part, index) => ({ label: part, - path: parts.slice(0, index + 1).join("/"), + path: root + parts.slice(0, index + 1).join(separator), kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), })), ]; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index fc77f48c2ca65..3b3bad7b25e3b 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -275,6 +275,22 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); +describe("relative links inside a rendered host file", () => { + it("anchor to the file's directory while workspace membership follows cwd", () => { + const meta = resolveMarkdownFileLinkMeta("appendix.md", "/repo", "/tmp/report"); + expect(meta).toMatchObject({ + filePath: "/tmp/report/appendix.md", + workspaceRelativePath: null, + }); + const inline = resolveInlineCodeFileLinkMeta("Makefile:12", "/repo", "/tmp/report"); + expect(inline).toMatchObject({ filePath: "/tmp/report/Makefile", line: 12 }); + expect(resolveMarkdownFileLinkMeta("src/main.ts", "/repo", "/repo/docs")).toMatchObject({ + filePath: "/repo/docs/src/main.ts", + workspaceRelativePath: "docs/src/main.ts", + }); + }); +}); + describe("resolveInlineCodeFileLinkMeta", () => { it("links relative paths with file extensions", () => { expect( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 7ea965e71bbcd..d55c9e1cd16c2 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -189,9 +189,15 @@ function hasExternalScheme(path: string): boolean { return !POSITION_ONLY_PATTERN.test(rest); } +/** + * `baseDir` anchors relative links; it defaults to the workspace root and is the + * file's own directory when rendering a markdown file. `cwd` stays the workspace + * root so the result still knows whether the target is inside it. + */ export function resolveMarkdownFileLinkTarget( href: string | undefined, cwd?: string, + baseDir: string | undefined = cwd, ): string | null { if (!href) return null; const rawHref = normalizeMarkdownLinkDestination(href); @@ -222,8 +228,8 @@ export function resolveMarkdownFileLinkTarget( return pathWithPosition; } - if (!cwd) return null; - return resolvePathLinkTarget(pathWithPosition, cwd); + if (!baseDir) return null; + return resolvePathLinkTarget(pathWithPosition, baseDir); } /** @@ -235,18 +241,19 @@ export function resolveMarkdownFileLinkTarget( export function resolveInlineCodeFileLinkMeta( codeText: string, cwd?: string, + baseDir: string | undefined = cwd, ): MarkdownFileLinkMeta | null { const candidate = inlineCodeFilePathCandidate(codeText); if (candidate === null) return null; - const resolved = resolveMarkdownFileLinkMeta(candidate, cwd); + const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); if (resolved) return resolved; // `Makefile:12` — conventional extensionless names fail the generic // markdown-link candidate patterns, but here the :line suffix already // marked the span as a file reference. - if (cwd && isConventionalFilePosition(candidate)) { - return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd); + if (baseDir && isConventionalFilePosition(candidate)) { + return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, baseDir), cwd); } return null; } @@ -276,8 +283,9 @@ function workspaceRelativePath(path: string, workspaceRoot: string | undefined): export function resolveMarkdownFileLinkMeta( href: string | undefined, cwd?: string, + baseDir: string | undefined = cwd, ): MarkdownFileLinkMeta | null { - const targetPath = resolveMarkdownFileLinkTarget(href, cwd); + const targetPath = resolveMarkdownFileLinkTarget(href, cwd, baseDir); if (!targetPath) return null; return buildFileLinkMetaFromTarget(targetPath, cwd); } diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 27d5ded5d272e..40038a2f0ebd1 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -41,6 +41,7 @@ export type RightPanelSurface = | { id: `file:${string}`; kind: "file"; + /** Workspace-relative, or absolute for a host file outside the workspace. */ relativePath: string; revealLine: number | null; revealRequestId: number; diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 1351a2a6c568c..be4258ba6b0f9 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -102,7 +102,7 @@ function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } -function isAbsolutePath(value: string): boolean { +export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index ac54e60dfcbbe..771b57e14b3b5 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -28,22 +28,31 @@ managed relay connectivity: The desktop bootstrap credential and command-line administrative bootstrap credentials additionally grant `access:read access:write relay:write`. +## Host file access + +Clients with `orchestration:read` can read files anywhere the environment's server account can +read, following the environment-wide authorization model rather than introducing per-project +filesystem permissions. `projects.readFile` accepts an absolute path and returns the text of that +host file; only workspace-relative paths pass its root check, and `projects.writeFile` never +accepts an absolute path. Clients use this to show files an agent wrote outside the workspace, such +as a report in a temp directory, read-only. + ## Media preview access Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for -supported images and videos anywhere the environment's server account can read. A thread ID -supplies the workspace for relative paths; absolute paths refer to the environment host, not the -client. This follows the environment-wide authorization model rather than introducing per-project -filesystem permissions. +supported images, videos, HTML, and PDF files anywhere the environment's server account can read. +A thread ID supplies the workspace for relative paths; absolute paths refer to the environment +host, not the client. [`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular file, and validates the resolved file's literal extension. It opens the file and signs its canonical path and device/inode identity for one hour. The token grants access to that exact file, not adjacent files or its containing directory. Serving rechecks the canonical path, media type, and opened descriptor's identity, then streams full or partial responses from that descriptor. Replacing a -file atomically requires a freshly signed URL; editing it in place does not. The existing workspace -boundary still applies to HTML, PDF, and other workspace previews. Uploaded attachments keep their -separate asset resource. +file atomically requires a freshly signed URL; editing it in place does not. Because the token names +one file, an HTML document served this way cannot load sibling assets; the directory-scoped +`workspace-file` resource remains the route for HTML inside the workspace. Uploaded attachments keep +their separate asset resource. Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment can fetch that file until it expires. Clients should copy the authored reference, not the temporary diff --git a/docs/user/composer.md b/docs/user/composer.md index 5ac2743814005..19bef9e31ad5c 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -94,6 +94,15 @@ have a cached copy. Supported video formats and codecs depend on the browser or Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `