From bb485efc4dfecad3ae2be12ad2a972e5b6dd48d9 Mon Sep 17 00:00:00 2001 From: Ali-Al1 <11404482+Ali-Al1@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:44:14 +0000 Subject: [PATCH 01/56] fix(server): treat git 2.56 broken-gitfile stderr as non-repository Git 2.56 reports a worktree whose `.git` gitfile points at a missing directory as "gitfile does not point to a valid repository" instead of "not a git repository". isNonRepositoryGitStderr only matched the old phrase, and StorageCleanup.branchChanged and GitManager each kept their own copy of the same check, so with git 2.56 storage cleanup reported such worktrees as `failed` rather than keeping them as changed. Export isNonRepositoryGitStderr, match both phrases, and reuse it in storageCleanup and GitManager so the checks cannot drift again. Co-Authored-By: Claude Code --- apps/server/src/git/GitManager.ts | 3 ++- apps/server/src/storageCleanup.ts | 3 ++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 22 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 12 +++++++++-- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index e6b4bdcfe4230..52220932043d4 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -86,6 +86,7 @@ import { detachStackFrame } from "./detachStackFrame.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { isNonRepositoryGitStderr } from "../vcs/GitVcsDriverCore.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -191,7 +192,7 @@ type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; function isNotGitRepositoryError(error: GitCommandError): boolean { - return error.message.toLowerCase().includes("not a git repository"); + return isNonRepositoryGitStderr(error.message); } interface OpenPrInfo { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index a5f9cd7eefa12..13fb7288ee057 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -47,6 +47,7 @@ import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import { isNonRepositoryGitStderr } from "./vcs/GitVcsDriverCore.ts"; import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -618,7 +619,7 @@ const make = Effect.gen(function* () { ); if (result === null) return null; if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; - if (result.stderr.toLowerCase().includes("not a git repository")) return null; + if (isNonRepositoryGitStderr(result.stderr)) return null; return yield* failure( result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index baf1368054d4e..9dd6ed197b920 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -37,6 +37,7 @@ import { import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as ServerConfig from "../config.ts"; import { + isNonRepositoryGitStderr, makeGitVcsDriverCore, parseGitCheckoutProgressLine, splitNullSeparatedGitStdoutPaths, @@ -130,6 +131,27 @@ const makeDriverReleasingLock = ( return { driver, failures }; }); +describe("isNonRepositoryGitStderr", () => { + it.each([ + "fatal: not a git repository (or any of the parent directories): .git", + "fatal: not a git repository: /missing-git-directory", + // Git 2.56+ wording for a `.git` gitfile that points at a missing directory. + "fatal: gitfile does not point to a valid repository: /tmp/worktrees/feature/.git", + "FATAL: GITFILE DOES NOT POINT TO A VALID REPOSITORY: /tmp/x/.git", + ])("classifies %j as a non-repository", (stderr) => { + assert.isTrue(isNonRepositoryGitStderr(stderr)); + }); + + it.each([ + "", + "fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.", + "fatal: Unable to create '/repo/.git/index.lock': File exists.", + "fatal: invalid gitfile format: /repo/.git", + ])("does not classify %j as a non-repository", (stderr) => { + assert.isFalse(isNonRepositoryGitStderr(stderr)); + }); +}); + it.effect("runs background Git in its own lane so user commands never queue behind it", () => Effect.gen(function* () { const gate = yield* Deferred.make(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4fcf1b05fe6f5..c0d6f9698a233 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -562,8 +562,16 @@ function isMissingGitCwdError(error: GitCommandError): boolean { ); } -function isNonRepositoryGitStderr(stderr: string): boolean { - return stderr.toLowerCase().includes("not a git repository"); +// Git 2.56+ reports a `.git` gitfile pointing nowhere as "gitfile does not point to a valid +// repository"; older releases said "not a git repository". Both mean the cwd is not a repository. +const NON_REPOSITORY_STDERR_PHRASES = [ + "not a git repository", + "gitfile does not point to a valid repository", +] as const; + +export function isNonRepositoryGitStderr(stderr: string): boolean { + const normalized = stderr.toLowerCase(); + return NON_REPOSITORY_STDERR_PHRASES.some((phrase) => normalized.includes(phrase)); } function isUnbornHeadStderr(stderr: string): boolean { const normalized = stderr.toLowerCase(); From 25631c3c838daf468005e825a034bd75bbb42372 Mon Sep 17 00:00:00 2001 From: Joseph Vidal <135430578+josephv123@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:16:44 -0500 Subject: [PATCH 02/56] fix(server): new worktrees refresh their index once, so captures and status stop re-hashing every file (#21341) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 74 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 58 +++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d9a5e7d2b9fde..70b2d50eda88e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -13,6 +13,7 @@ import * as Exit from "effect/Exit"; 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 Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as PlatformError from "effect/PlatformError"; @@ -4678,6 +4679,79 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.isTrue(Result.isSuccess(result), "worktree creation failed in the hook"); }), ); + it.effect("refreshes a new worktree's index once its checkout second is over", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + for (let file = 0; file < 50; file++) { + yield* writeTextFile(cwd, `src/file-${file}.txt`, `contents ${file}\n`); + } + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "more files"]); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "fresh"); + + // The refresh waits on the clock that stamps the files. + yield* TestClock.withLive( + driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/fresh", + }), + ); + + // Git re-reads every entry stamped in the index's own second or later. + const indexPath = path.resolve( + worktreePath, + yield* git(worktreePath, ["rev-parse", "--git-path", "index"]), + ); + const indexMtime = Option.getOrThrow((yield* fs.stat(indexPath)).mtime); + const indexSecond = Math.floor(indexMtime.getTime() / 1000); + const entrySeconds = Array.from( + (yield* git(worktreePath, ["ls-files", "--debug"])).matchAll(/^ {2}mtime: (\d+):/gm), + ([, seconds]) => Number(seconds), + ); + assert.equal(entrySeconds.length, 51); + assert.deepEqual( + entrySeconds.filter((seconds) => seconds >= indexSecond), + [], + ); + }), + ); + it.effect("returns the worktree when its index refresh fails", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + // Something else takes the new worktree's index lock as soon as its checkout ends. + yield* writeTextFile( + cwd, + ".git/hooks/post-checkout", + '#!/bin/sh\ntouch "$(git rev-parse --git-path index).lock"\n', + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "locked"); + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/locked", + }); + + assert.equal(created.worktree.path, worktreePath); + const indexPath = path.resolve( + worktreePath, + yield* git(worktreePath, ["rev-parse", "--git-path", "index"]), + ); + assert.isTrue(yield* fs.exists(`${indexPath}.lock`)); + }), + ); it.effect.each(["commit", "short-commit", "tag", "revision"] as const)( "creates a worktree from a %s without recording it as a PR base", (kind) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4dabf3b493ebe..3d8122f197971 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -65,6 +65,7 @@ const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base` const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; const gitProcesses = Semaphore.makeUnsafe(8); const backgroundGitProcesses = Semaphore.makeUnsafe(2); +const WORKTREE_INDEX_REFRESH_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; @@ -4247,6 +4248,52 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + // A checkout stamps most files in the second it writes the index, and Git re-reads every file + // stamped in the index's own second or later ("racily clean") on each status, diff and add. + // T3's own git calls never rewrite a worktree's index, so refresh it once. + const refreshNewWorktreeIndex = Effect.fn("refreshNewWorktreeIndex")(function* ( + worktreePath: string, + ) { + const indexPath = path.resolve( + worktreePath, + (yield* runGitStdout("GitVcsDriver.createWorktree.indexPath", worktreePath, [ + "rev-parse", + "--git-path", + "index", + ])).trim(), + ); + const indexSecond = fileSystem.stat(indexPath).pipe( + Effect.map(({ mtime }) => Option.map(mtime, (date) => Math.floor(date.getTime() / 1000))), + Effect.orElseSucceed(() => Option.none()), + ); + // Older Git (such as 2.30) skips the write when racy entries merely check out clean. + // A refresh re-reads every checked-out file, so allow five minutes outside shared permits. + const refresh = runGit( + "GitVcsDriver.createWorktree.refreshIndex", + worktreePath, + ["update-index", "-q", "--refresh", "--force-write-index"], + { timeoutMs: WORKTREE_INDEX_REFRESH_TIMEOUT_MS }, + ); + const checkoutSecond = yield* indexSecond; + yield* refresh; + const refreshedSecond = yield* indexSecond; + if ( + Option.isNone(checkoutSecond) || + Option.isNone(refreshedSecond) || + refreshedSecond.value > checkoutSecond.value + ) { + return; + } + // The refresh finished within the checkout's second, so its index is just as racy. Wait that + // second out (file timestamps can trail the clock by a kernel tick) and refresh again. Two + // seconds or more means this clock does not match the file timestamps, as on a share stamped + // by another machine, so it cannot say when that second ends. + const delay = (checkoutSecond.value + 1) * 1000 + 50 - (yield* Clock.currentTimeMillis); + if (delay >= 2_000) return; + if (delay > 0) yield* Effect.sleep(Duration.millis(delay)); + yield* refresh; + }); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input, options) { @@ -4444,6 +4491,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } + // Before the worktree is returned: a refresh holds index.lock, so running it in the + // background would race the setup script's and the agent's own git commands. + yield* refreshNewWorktreeIndex(worktreePath).pipe( + Effect.catch((cause) => + Effect.logWarning( + "worktree index refresh failed; Git keeps re-reading its files until the index is rewritten", + { worktreePath, cause }, + ), + ), + ); + if (newRefName && base) { yield* Effect.gen(function* () { yield* writeConfigValue({ From e2a781477afd2aea1741f720afa9067294e7a1b7 Mon Sep 17 00:00:00 2001 From: Jaewook Oh Date: Wed, 11 Nov 2026 18:21:58 +0900 Subject: [PATCH 03/56] fix(usage): Grok and Cursor usage limits show pace and the even-spend line (#21897) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/provider/GrokProvider.test.ts | 63 ++++++++++++++++++- .../src/server/usageLimits.test.ts | 34 ++++++++++ .../provider-cursor/src/server/usageLimits.ts | 7 +++ .../provider-grok/src/server/usageLimits.ts | 11 ++-- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 9be799112cc23..be899ae540037 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -478,8 +478,8 @@ describe("Grok usage limits", () => { }; it("shows zero usage for complete weekly and monthly periods with no percentage", () => { - for (const [currentPeriod, kind, label, resetsAt] of [ - [weeklyPeriod, "weekly", "Weekly", "2026-09-18T03:10:30.159Z"], + for (const [currentPeriod, kind, label, resetsAt, windowDurationMins] of [ + [weeklyPeriod, "weekly", "Weekly", "2026-09-18T03:10:30.159Z", 7 * 24 * 60], [ { type: "USAGE_PERIOD_TYPE_MONTHLY", @@ -489,12 +489,15 @@ describe("Grok usage limits", () => { "monthly", "Monthly", "2026-10-01T00:00:00.000Z", + 30 * 24 * 60, ], ] as const) { const response = { config: { currentPeriod } }; expect .soft(grokUsageResponseToLimits(response, checkedAt).windows) - .toEqual([{ id: "subscription", kind, label, usedPercent: 0, resetsAt }]); + .toEqual([ + { id: "subscription", kind, label, usedPercent: 0, resetsAt, windowDurationMins }, + ]); } }); @@ -551,6 +554,60 @@ describe("Grok usage limits", () => { ).windows, ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]); }); + + it("measures the window from the reported period bounds", () => { + const window = (type: string, start: string, end: string) => + grokUsageResponseToLimits( + { config: { creditUsagePercent: 4, currentPeriod: { type, start, end } } }, + checkedAt, + ).windows[0]; + expect( + window( + "USAGE_PERIOD_TYPE_WEEKLY", + "2026-10-14T20:51:37.352+00:00", + "2026-10-21T20:51:37.352+00:00", + ), + ).toEqual({ + id: "subscription", + kind: "weekly", + label: "Weekly", + usedPercent: 4, + resetsAt: "2026-10-21T20:51:37.352Z", + windowDurationMins: 7 * 24 * 60, + }); + expect( + window( + "USAGE_PERIOD_TYPE_MONTHLY", + "2026-02-01T00:00:00.000+00:00", + "2026-03-01T00:00:00.000+00:00", + )?.windowDurationMins, + ).toBe(28 * 24 * 60); + }); + + it("leaves the window length unknown when the period start is unusable", () => { + const end = "2026-10-21T20:51:37.352+00:00"; + for (const start of [ + undefined, + null, + 42, + "invalid", + "2026-02-30T00:00:00.000Z", + end, + "2026-10-28T20:51:37.352+00:00", + ]) { + const [window] = grokUsageResponseToLimits( + { + config: { + creditUsagePercent: 4, + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start, end }, + }, + }, + checkedAt, + ).windows; + expect(window?.resetsAt).toBe("2026-10-21T20:51:37.352Z"); + expect(window).not.toHaveProperty("windowDurationMins"); + } + }); }); it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("readGrokAccount", (it) => { diff --git a/packages/provider-cursor/src/server/usageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts index 0b30313c46058..0b54c7b5acefe 100644 --- a/packages/provider-cursor/src/server/usageLimits.test.ts +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -42,6 +42,40 @@ describe("Cursor usage limits", () => { ); }); + it("reports the billing-cycle length so pace can be computed", () => { + const planUsage = { totalPercentUsed: 40, autoPercentUsed: 30, apiPercentUsed: 20 }; + const durations = (billingCycleStart: string | number | undefined, billingCycleEnd: string) => + cursorUsageResponseToLimits( + { billingCycleStart, billingCycleEnd, planUsage }, + checkedAt, + ).windows.map((window) => window.windowDurationMins); + expect(durations("1794045847072", "1796637847072")).toEqual([43_200, 43_200, 43_200]); + expect(durations(1_796_637_847_072, "1799316247072")).toEqual([44_640, 44_640, 44_640]); + }); + + it("omits the cycle length when the start is missing or invalid", () => { + for (const billingCycleStart of [undefined, "", "0", "not-a-number", "1796637847072"]) { + expect( + cursorUsageResponseToLimits( + { + billingCycleStart, + billingCycleEnd: "1796637847072", + planUsage: { totalPercentUsed: 40 }, + }, + checkedAt, + ).windows, + ).toEqual([ + { + id: "totalPercentUsed", + kind: "monthly", + label: "Overall", + usedPercent: 40, + resetsAt: "2026-12-07T10:04:07.072Z", + }, + ]); + } + }); + it("does not invent unused allowance for absent buckets", () => { expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( "unsupported", diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index a0746c3e013bf..48ab708554206 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -16,6 +16,7 @@ import { import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; const CursorUsageResponse = Schema.Struct({ + billingCycleStart: Schema.optional(Schema.Union([Schema.String, Schema.Number])), billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), planUsage: Schema.optional( Schema.Struct({ @@ -36,6 +37,11 @@ export function cursorUsageResponseToLimits( Number(response.billingCycleEnd) > 0 && Option.isSome(reset) ? DateTime.formatIso(reset.value) : undefined; + const cycleStart = Number(response.billingCycleStart); + const windowDurationMins = + resetsAt && cycleStart > 0 + ? Math.round((Number(response.billingCycleEnd) - cycleStart) / 60_000) + : 0; const windows: ServerProviderUsageWindow[] = []; if (response.planUsage) { for (const { id, label } of CURSOR_USAGE_WINDOWS) { @@ -47,6 +53,7 @@ export function cursorUsageResponseToLimits( label, usedPercent: clampPercent(usedPercent), ...(resetsAt ? { resetsAt } : {}), + ...(windowDurationMins > 0 ? { windowDurationMins } : {}), }); } } diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index 5bf1ab4fda57f..d7e97518761fe 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -69,13 +69,11 @@ export function grokUsageResponseToLimits( const period = response.config?.currentPeriod; const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, ""); const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other"; + const start = typeof period?.start === "string" ? parsePeriodBoundary(period.start) : NaN; + const end = parsePeriodBoundary(period?.end); + const windowDurationMins = Math.round((end - start) / 60_000); let usedPercent = response.config?.creditUsagePercent; - if ( - usedPercent === undefined && - (kind === "weekly" || kind === "monthly") && - typeof period?.start === "string" && - parsePeriodBoundary(period?.start) < parsePeriodBoundary(period?.end) - ) { + if (usedPercent === undefined && (kind === "weekly" || kind === "monthly") && start < end) { // Grok omits a zero percentage while retaining the complete subscription period. usedPercent = 0; } @@ -89,6 +87,7 @@ export function grokUsageResponseToLimits( label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription", usedPercent: clampPercent(usedPercent), ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}), + ...(windowDurationMins > 0 ? { windowDurationMins } : {}), }; return makeUsageLimits({ checkedAt, windows: [window] }); } From 996b43f01b2bfe3720e2a974940ed963ff144f25 Mon Sep 17 00:00:00 2001 From: Alessandro Sola <59030130+aleo113@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:27:12 +0100 Subject: [PATCH 04/56] fix(web): retain diff selection across navigation (#22347) Co-authored-by: Lukas Tomondy Co-authored-by: Julius Marminge --- .../src/components/ChatView.browser.test.tsx | 70 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 10 --- apps/web/src/components/ChatView.tsx | 16 ----- .../src/components/DiffPanel.browser.test.tsx | 1 - .../components/chat/useRightPanelActions.ts | 15 ---- apps/web/src/diffPanelStore.ts | 18 +---- .../lib/openPullRequestLink.browser.test.tsx | 3 - apps/web/src/lib/openPullRequestLink.ts | 9 +-- 8 files changed, 73 insertions(+), 69 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index dc4908867a3e1..32ccf9465ba3e 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -1,5 +1,5 @@ import { createMemoryHistory } from "@tanstack/react-router"; -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; @@ -11,7 +11,9 @@ import { AppRoot } from "~/AppRoot"; // takes 6-11s; importing it here keeps that out of the test's timeout. import "~/components/ChatView"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { useRightPanelStore } from "~/rightPanelStore"; import { getRouter } from "~/router"; import { ENVIRONMENT_ID, @@ -20,7 +22,7 @@ import { threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; -import { render } from "../../test/render"; +import { act, render } from "../../test/render"; // The socket is the only fake layer; see test/appScenarios.renders.tsx. vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { @@ -91,3 +93,67 @@ it("opens the thread actions menu for an older settled thread the shell leaves o .element(page.getByRole("menuitem", { name: "Rename thread" }), { timeout: 5_000 }) .toBeVisible(); }); + +it("retains each thread's diff selection when returning from another thread or Settings", async () => { + const a = threadWithHistory("diff-selection-a", 1); + const b = threadWithHistory("diff-selection-b", 1); + using _environment = installFakeEnvironment([a, b]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const aRef = scopeThreadRef(ENVIRONMENT_ID, a.id); + const bRef = scopeThreadRef(ENVIRONMENT_ID, b.id); + useRightPanelStore.getState().open(aRef, "diff"); + useRightPanelStore.getState().open(bRef, "diff"); + useDiffPanelStore.getState().selectBranchBaseRef(bRef, "origin/release"); + const router = getRouter(createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${a.id}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible(); + await act(() => useDiffPanelStore.getState().selectGitScope(aRef, "unstaged")); + + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: b.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${b.id}.`)).toBeVisible(); + expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, bRef)).toEqual({ + kind: "branch", + baseRef: "origin/release", + }); + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: a.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible(); + expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, aRef)).toEqual({ + kind: "unstaged", + }); + + await act(() => useDiffPanelStore.getState().selectTurn(aRef, a.runs[0]!.id)); + await act(() => router.navigate({ to: "/settings/general" })); + await expect + .element(page.getByRole("heading", { name: "Time format", exact: true })) + .toBeVisible(); + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: a.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible(); + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, aRef), + ).toMatchObject({ + kind: "turn", + turnId: a.runs[0]!.id, + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 09a25ee55910d..d35b6ce28c433 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -230,16 +230,6 @@ export function resolveProactiveTurnDiffAction(input: { return totals.count >= 3 || totals.additions + totals.deletions >= 50 ? "open" : "ignore"; } -/** Mark reactivated Diff tabs to preserve review scope. Already-active tabs must not leave a marker for - * the next open. */ -export function resolveExplicitDiffOpenOnActivate(input: { - readonly surfaceKind: RightPanelSurface["kind"]; - readonly diffOpen: boolean; - readonly activeThreadRef: ScopedThreadRef; -}): ScopedThreadRef | null { - return input.surfaceKind === "diff" && !input.diffOpen ? input.activeThreadRef : null; -} - export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f4561188df0cc..7d98857945cd5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -133,7 +133,6 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; -import { useDiffPanelStore } from "../diffPanelStore"; import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; import { composerRequiresModifier } from "../composer-logic"; import { @@ -1031,20 +1030,6 @@ export default function ChatView(props: ChatViewProps) { selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); const diffOpen = activeRightPanelKind === "diff"; - const explicitDiffOpenRef = useRef(null); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resets the diff panel store to branch scope when the diff opens without an explicit request - useLayoutEffect(() => { - const explicitThreadRef = explicitDiffOpenRef.current; - explicitDiffOpenRef.current = null; - if ( - diffOpen && - activeThreadRef && - explicitThreadRef !== activeThreadRef && - !useDiffPanelStore.getState().consumeKeepSelectionOnOpen(activeThreadRef) - ) { - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); - } - }, [activeThreadRef, diffOpen]); const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); @@ -2374,7 +2359,6 @@ export default function ChatView(props: ChatViewProps) { pullRequestsSurfaceAvailable, gitCwd, activeProjectCwd, - explicitDiffOpenRef, onDiffPanelOpen, closeRightPanelSurface, }); diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 874118daf306f..5f789469af763 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -121,7 +121,6 @@ function setup(failedBaseRef: string | null) { useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {}, - keepSelectionOnOpenThreadKey: null, }); useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); }; diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index 25ec5c22c2e1a..33fbb938a3c12 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -3,7 +3,6 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { ThreadPreviewState } from "../../previewStateStore"; import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; -import { resolveExplicitDiffOpenOnActivate } from "../ChatView.logic"; import { useStableCallback } from "../../hooks/useStableCallback"; import { type ScopedThreadRef, type RunId } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; @@ -15,7 +14,6 @@ import { import { useDiffPanelStore } from "../../diffPanelStore"; import { type ThreadPanelPresentation } from "../../rightPanelLayout"; import { - selectActiveRightPanel, selectThreadPanelOpen, type RightPanelSurface, useRightPanelStore, @@ -59,7 +57,6 @@ interface RightPanelActionsInput { readonly pullRequestsSurfaceAvailable: boolean; readonly gitCwd: string | null; readonly activeProjectCwd: string | null; - readonly explicitDiffOpenRef: { current: ScopedThreadRef | null }; readonly onDiffPanelOpen: (() => void) | undefined; readonly closeRightPanelSurface: (surface: RightPanelSurface) => void; } @@ -93,7 +90,6 @@ export function useRightPanelActions(input: RightPanelActionsInput) { pullRequestsSurfaceAvailable, gitCwd, activeProjectCwd, - explicitDiffOpenRef, onDiffPanelOpen, } = input; @@ -201,11 +197,6 @@ export function useRightPanelActions(input: RightPanelActionsInput) { const togglePreviewPanelStable = useStableCallback(togglePreviewPanel); const activateRightPanelSurface = (surface: RightPanelSurface) => { if (!activeThreadRef) return; - explicitDiffOpenRef.current = resolveExplicitDiffOpenOnActivate({ - surfaceKind: surface.kind, - diffOpen, - activeThreadRef, - }); useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); @@ -299,18 +290,12 @@ export function useRightPanelActions(input: RightPanelActionsInput) { const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface); const onOpenTurnDiff = (runId: RunId, filePath?: string) => { if (!isServerThread || !activeThreadRef) return; - const diffOpen = - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; - explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }; const onOpenToolDiffStable = useStableCallback((target: ToolCallDiffTarget) => { if (!isServerThread || !activeThreadRef) return; - const diffOpen = - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; - explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; useDiffPanelStore.getState().selectToolCall(activeThreadRef, target); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index 6bd7eb0e01bdd..39f89fe9ee982 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -25,14 +25,11 @@ const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; interface DiffPanelStoreState { byThreadKey: Record; branchBaseRefByThreadKey: Record; - /** A link that selected a commit before opening the closed pane; opening keeps that selection. */ - keepSelectionOnOpenThreadKey: string | null; selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; selectToolCall: (ref: ScopedThreadRef, target: ToolCallDiffTarget) => void; selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void; - selectCommit: (ref: ScopedThreadRef, sha: string, keepOnOpen?: boolean) => void; - consumeKeepSelectionOnOpen: (ref: ScopedThreadRef) => boolean; + selectCommit: (ref: ScopedThreadRef, sha: string) => void; reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -54,10 +51,9 @@ function branchBaseRef( export const useDiffPanelStore = create()( persist( - (set, get) => ({ + (set) => ({ byThreadKey: {}, branchBaseRefByThreadKey: {}, - keepSelectionOnOpenThreadKey: null, selectGitScope: (ref, scope) => set((state) => { const threadKey = scopedThreadKey(ref); @@ -115,7 +111,7 @@ export const useDiffPanelStore = create()( }, }; }), - selectCommit: (ref, sha, keepOnOpen = false) => + selectCommit: (ref, sha) => set((state) => { const threadKey = scopedThreadKey(ref); const baseRef = branchBaseRef(state, threadKey); @@ -125,16 +121,8 @@ export const useDiffPanelStore = create()( [threadKey]: { kind: "commit", sha, baseRef }, }, branchBaseRefByThreadKey: { ...state.branchBaseRefByThreadKey, [threadKey]: baseRef }, - keepSelectionOnOpenThreadKey: keepOnOpen - ? threadKey - : state.keepSelectionOnOpenThreadKey, }; }), - consumeKeepSelectionOnOpen: (ref) => { - if (get().keepSelectionOnOpenThreadKey !== scopedThreadKey(ref)) return false; - set({ keepSelectionOnOpenThreadKey: null }); - return true; - }, reconcileTurnSelection: (ref, availableTurnIds) => set((state) => { const threadKey = scopedThreadKey(ref); diff --git a/apps/web/src/lib/openPullRequestLink.browser.test.tsx b/apps/web/src/lib/openPullRequestLink.browser.test.tsx index 7d93882b12b15..2d7aceeb304dc 100644 --- a/apps/web/src/lib/openPullRequestLink.browser.test.tsx +++ b/apps/web/src/lib/openPullRequestLink.browser.test.tsx @@ -76,7 +76,6 @@ function setup() { useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {}, - keepSelectionOnOpenThreadKey: null, }); useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); } @@ -157,6 +156,4 @@ it("opens an available commit in the closed Diff pane without the pane resetting [scopedThreadKey(threadRef)]: { kind: "commit", sha, baseRef: null }, }), ); - // ChatView resets a pane opened without an explicit request to Changes unless this is set. - expect(useDiffPanelStore.getState().consumeKeepSelectionOnOpen(threadRef)).toBe(true); }); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 74a0b29d7aa22..9b6591f34be0e 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -15,7 +15,7 @@ import { canOpenLinksInApp } from "../browser/browserLinkTarget"; import { useOpenLink } from "../browser/useOpenLink"; import { useClientSettings } from "../hooks/useSettings"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { useRightPanelStore } from "../rightPanelStore"; import { selectThreadBranchBaseRef, selectThreadDiffPanelSelection, @@ -199,12 +199,7 @@ export function useOpenChangeRequestLink( (source) => source.kind === "commit" && source.headRef === commit.sha, ) ) { - const diffOpen = - selectActiveRightPanel( - useRightPanelStore.getState().byThreadKey, - resolvedThreadRef, - ) === "diff"; - useDiffPanelStore.getState().selectCommit(resolvedThreadRef, commit.sha, !diffOpen); + useDiffPanelStore.getState().selectCommit(resolvedThreadRef, commit.sha); useRightPanelStore.getState().open(resolvedThreadRef, "diff"); return; } From 0d36f2b48e88676d27ebef8c380ec64f3e8e1c37 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:32:27 -0800 Subject: [PATCH 05/56] fix(mobile): subscription widget no longer expires on a window that already reset (#22993) Co-authored-by: Claude Opus 5.5 --- .../widgets/subscriptionUsageSnapshot.test.ts | 59 +++++++++++++++++++ .../src/widgets/subscriptionUsageSnapshot.ts | 13 +++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index abc4fb0ed0c95..2b88a45db8ff1 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -135,6 +135,65 @@ describe("subscription widget snapshots", () => { }); }); +describe("subscription widget expiry", () => { + const maxAgeDeadline = now + 15 * 60_000; + function single(resetsAt: string) { + return buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { checkedAt, windows: [{ ...window, resetsAt }] } })]), + deepLink, + ).providers[0]; + } + + it.each(["2026-09-05T11:50:00.000Z", checkedAt])( + "keeps usage whose reset at %s already happened when observed", + (resetsAt) => { + expect(single(resetsAt)).toMatchObject({ + detail: "Subscription remaining", + expiresAt: maxAgeDeadline, + windows: [{ remaining: 60, reset: expect.stringMatching(/^Reset /) }], + }); + }, + ); + + it.each([ + { resetAccount: 1, expiresAt: maxAgeDeadline, label: /^Reset / }, + { resetAccount: 0, expiresAt: now + 2 * 60_000, label: /^Next reset / }, + ])( + "compares a pooled reset with its own account's observation (account $resetAccount)", + ({ resetAccount, expiresAt, label }) => { + // Account 0 was observed at 12:00, account 1 at 12:05; the pooled reset is at 12:02. + const snapshot = buildSubscriptionUsageSnapshot( + presentations( + [checkedAt, "2026-09-05T12:05:00.000Z"].map((accountCheckedAt, index) => + provider({ + instanceId: ProviderInstanceId.make(`codex-${index}`), + auth: { status: "authenticated", email: `account-${index}@example.com` }, + usageLimits: { + checkedAt: accountCheckedAt, + windows: [ + { + ...window, + resetsAt: + index === resetAccount + ? "2026-09-05T12:02:00.000Z" + : "2026-09-05T13:00:00.000Z", + }, + ], + }, + }), + ), + ), + deepLink, + ); + expect(snapshot.providers[0]).toMatchObject({ + detail: "2 accounts · pooled", + expiresAt, + windows: [{ remaining: 60, reset: expect.stringMatching(label) }], + }); + }, + ); +}); + describe("widget refresh probes", () => { it("does not overlap a slow probe even after the refresh interval", async () => { let finish!: () => void; diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index b96b5c919ec30..db99110c5d357 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -2,6 +2,7 @@ import { collectLimitAccounts, collectLimitPools, type LimitAccount, + type LimitPoolWindow, type LimitPresentations, } from "@t3tools/shared/usageLimits"; @@ -44,6 +45,10 @@ export function createWidgetRefresher(refresh: (id: Id) => Promise) }; } +// A reset at or before its account's observation already happened, so it cannot expire the snapshot. +const isUpcoming = (reset: LimitPoolWindow["resets"][number]) => + reset.at > Date.parse(reset.member.account.limits.checkedAt); + function subscriptionUsageProps( accounts: readonly LimitAccount[], now: number, @@ -75,7 +80,9 @@ function subscriptionUsageProps( const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); const expiresAt = Math.min( checkedAt + SNAPSHOT_MAX_AGE, - ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), + ...pool.windows.flatMap((window) => + window.resets.filter(isUpcoming).map((reset) => reset.at), + ), ); const fresh = Number.isFinite(expiresAt) && expiresAt > now; const sortedWindows = [...pool.windows].sort( @@ -106,7 +113,9 @@ function subscriptionUsageProps( label: window.label, remaining: Math.round(window.remainingPercent), reset: window.resets[0] - ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { + ? `${isUpcoming(window.resets[0]) ? "Next reset" : "Reset"} ${new Date( + window.resets[0].at, + ).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", From b5256ad09c0553347a6ddfd2d30de9b9c12c79d5 Mon Sep 17 00:00:00 2001 From: intrepid37 <112969308+intrepid37@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:37:51 -0500 Subject: [PATCH 06/56] fix(opencode): verify CLI updates when loopback server is still running (#22839) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../providerMaintenanceRunner.test.ts | 30 ++++++-- .../src/provider/providerMaintenanceRunner.ts | 20 +++++- ...iderUpdateLaunchNotification.logic.test.ts | 35 +++++++++ .../ProviderUpdateLaunchNotification.logic.ts | 49 +++++++++---- .../settings/ProviderInstanceCard.tsx | 8 +++ docs/user/providers-opencode.md | 6 ++ .../src/server/driver.test.ts | 72 ++++++++++++++++++- .../provider-opencode/src/server/driver.ts | 44 +++++++++++- .../src/server/versionProbe.ts | 2 +- 9 files changed, 241 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index adf53c5267203..b44b6c3749789 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -978,6 +978,12 @@ describe("providerMaintenanceRunner", () => { }); it.effect.each([ + { + mode: "default-precurrent", + version: "1.14.19", + installation: { installedVersion: "1.14.20" }, + status: "succeeded", + }, { mode: "default", version: "1.14.19", @@ -1033,7 +1039,11 @@ it.effect.each([ ...registry, refreshInstance: () => Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ ...provider, checkedAt: "2026-04-10T00:00:01.000Z" })), + providers.map((provider) => ({ + ...provider, + ...(mode === "default-precurrent" ? installation : {}), + checkedAt: "2026-04-10T00:00:01.000Z", + })), ), getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), refreshAfterUpdate: () => @@ -1059,17 +1069,27 @@ it.effect.each([ const result = yield* updater.updateProvider({ provider: OPENCODE_DRIVER, - ...(mode === "default" ? {} : { targetVersion: "1.14.20" }), + ...(mode.startsWith("default") ? {} : { targetVersion: "1.14.20" }), }); - assert.deepStrictEqual(calls, [ - `npm install -g opencode-ai@${mode === "default" ? "latest" : "1.14.20"}`, - ]); + assert.deepStrictEqual( + calls, + mode === "default-precurrent" + ? [] + : [`npm install -g opencode-ai@${mode.startsWith("default") ? "latest" : "1.14.20"}`], + ); const provider = result.providers[0]; assert.ok(provider); assert.strictEqual(provider.updateState?.status, status); + if (mode === "default" || mode === "default-precurrent" || mode === "targeted") { + assert.strictEqual( + provider.updateState?.message, + "OpenCode CLI updated. Restart the configured local OpenCode server to use the new version.", + ); + } assert.strictEqual(provider.version, version); assert.strictEqual("installedVersion" in provider, mode !== "legacy"); + if (mode === "default-precurrent") return; assert.strictEqual( provider.versionAdvisory?.status, mode === "unknown" ? "unknown" : "current", diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index d3fe7fdf86739..b37fbccd8ce20 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -94,6 +94,18 @@ interface VerifiedProviderRefresh { const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const OPENCODE_SERVER_RESTART_MESSAGE = + "OpenCode CLI updated. Restart the configured local OpenCode server to use the new version."; + +const needsOpenCodeServerRestart = (providers: ReadonlyArray) => + providers.some( + (provider) => + provider.driver === ProviderDriverKind.make("opencode") && + provider.installedVersion != null && + provider.version != null && + provider.installedVersion !== provider.version, + ); + const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceRunner.runCommand")( function* (input: { readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; @@ -527,7 +539,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { status: "succeeded", startedAt, finishedAt: yield* nowIso, - message: "Already up to date.", + message: needsOpenCodeServerRestart(precheck.verifiedProviders) + ? OPENCODE_SERVER_RESTART_MESSAGE + : "Already up to date.", }), ); } @@ -600,7 +614,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ? "Update command completed, but T3 Code could not verify the provider version." : stillOutdated ? "Update command completed, but T3 Code still detects an outdated provider version." - : "Provider updated.", + : needsOpenCodeServerRestart(verifiedProviders) + ? OPENCODE_SERVER_RESTART_MESSAGE + : "Provider updated.", output: commandOutput(result), }), ); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index d6e283fec8dbb..764a76ba74f12 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -318,6 +318,41 @@ describe("getProviderUpdateRunToastView", () => { result, }); + it("preserves the local server restart instruction after a successful update", () => { + const message = + "OpenCode CLI updated. Restart the configured local OpenCode server to use the new version."; + const updated = { + ...provider({ + driver: driver("opencode"), + version: "2.1.2", + advisoryStatus: "current", + updateState: updateState("succeeded", message), + }), + installedVersion: "2.2.5", + }; + expect( + getProviderUpdateRunToastView([ + run("Linux", "opencode", AsyncResult.success({ providers: [updated] })), + run("Laptop", "opencode", AsyncResult.success({ providers: [updated] })), + ]), + ).toMatchObject({ + type: "success", + description: `Linux · OpenCode: ${message}\nLaptop · OpenCode: ${message}`, + }); + expect( + getProviderUpdateSidebarPillView([updated], { + visibleAfterIso: sessionStartedAt, + }), + ).toMatchObject({ title: "OpenCode updated: v2.2.5", description: message }); + + const restarted = { ...updated, version: "2.2.5" }; + expect( + getProviderUpdateRunToastView([ + run("Linux", "opencode", AsyncResult.success({ providers: [restarted] })), + ]), + ).toMatchObject({ description: "New sessions will use the updated provider." }); + }); + it("lists every failed update and ignores interrupted ones", () => { const view = getProviderUpdateRunToastView([ run( diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 1c8a97a998112..9c9720d7e7e30 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -78,11 +78,24 @@ function dedupeProvidersByDriver(providers: ReadonlyAr return [...latestProviderByDriver.values()]; } -function getProviderUpdatedTitle(provider: Pick): string { +function getProviderUpdatedTitle( + provider: Pick, +): string { const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; - return provider.version - ? `${providerName} updated: ${formatVersion(provider.version)}` - : `${providerName} updated`; + const version = provider.installedVersion ?? provider.version; + return version ? `${providerName} updated: ${formatVersion(version)}` : `${providerName} updated`; +} + +// Only OpenCode reports an installed CLI apart from a running external server. +export function getProviderRestartNotice( + provider: Pick, +): string | null { + return provider.updateState?.status === "succeeded" && + provider.installedVersion != null && + provider.version != null && + provider.installedVersion !== provider.version + ? provider.updateState.message + : null; } function getProviderUpdatedDescription(providerCount: number): string { @@ -200,7 +213,8 @@ export interface ProviderUpdateRun { export function getProviderUpdateRunOutcome( run: ProviderUpdateRun, ): - | { readonly status: "interrupted" | "updated" } + | { readonly status: "interrupted" } + | { readonly status: "updated"; readonly message: string | null } | { readonly status: "failed"; readonly line: string } { if (isAtomCommandInterrupted(run.result)) return { status: "interrupted" }; const label = `${run.machineLabel} · ${PROVIDER_DISPLAY_NAMES[run.driver] ?? run.driver}`; @@ -211,11 +225,13 @@ export function getProviderUpdateRunOutcome( line: `${label}: ${error instanceof Error ? error.message : "Provider update failed."}`, }; } - const updateState = run.result.value.providers.find( - (provider) => provider.instanceId === run.instanceId, - )?.updateState; + const provider = run.result.value.providers.find( + (candidate) => candidate.instanceId === run.instanceId, + ); + const updateState = provider?.updateState; + const restartNotice = provider ? getProviderRestartNotice(provider) : null; return updateState?.status === "succeeded" - ? { status: "updated" } + ? { status: "updated", message: restartNotice ? `${label}: ${restartNotice}` : null } : { status: "failed", line: `${label}: ${updateState?.message ?? "Provider update did not finish."}`, @@ -236,11 +252,18 @@ export function getProviderUpdateRunToastView(runs: ReadonlyArray outcome.status === "failed" ? [outcome.line] : [], ); + const successMessages = [ + ...new Set( + settled.flatMap((outcome) => + outcome.status === "updated" && outcome.message ? [outcome.message] : [], + ), + ), + ]; if (failureLines.length === 0) { return { type: "success", title: settled.length === 1 ? "Provider updated" : `${settled.length} providers updated`, - description: getProviderUpdatedDescription(settled.length), + description: successMessages.join("\n") || getProviderUpdatedDescription(settled.length), }; } return { @@ -251,7 +274,7 @@ export function getProviderUpdateRunToastView(runs: ReadonlyArray + {restartNotice ? ( +

+ {restartNotice} +

+ ) : null} {urlAuthAction && onAcceptUrlAuth ? (

{urlAuthAction.message}

diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index ccf522d93fcbe..b73d4d93e5207 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -41,6 +41,12 @@ settings. T3 Code uses only that configured password for an external server; it does not forward a local `OPENCODE_SERVER_PASSWORD`. If connection or version checks fail, check the URL, credentials, and OpenCode version, then refresh provider status. +When **Server URL** points to a locally running server (such as `127.0.0.1`), +the update button upgrades its installed CLI without terminating the running server. +The server can still report the previous version until you restart it. T3 Code +shows that restart requirement instead of treating the CLI update as a failure. +Restart the server when active sessions can safely be interrupted. + After a lost connection, send another prompt to reconnect to the same OpenCode session. diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 0dcb6cc53997d..a3e7024426023 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -35,11 +35,23 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import { OpenCodeDriver, isLocalOpenCodeServerUrl, openCodeUpdateFor } from "./driver.ts"; import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; import { OPENCODE_2_RESPONSES, replayOpenCodeServer } from "./probeResponses.fixture.ts"; -import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; +it("only probes a locally configured OpenCode server's installed CLI", () => { + assert.isTrue(isLocalOpenCodeServerUrl("http://127.0.0.1:35768")); + assert.isTrue(isLocalOpenCodeServerUrl("http://127.255.255.254:4096")); + assert.isFalse(isLocalOpenCodeServerUrl("http://127.example.com:4096")); + assert.isFalse(isLocalOpenCodeServerUrl("http://128.0.0.1:4096")); + assert.isTrue(isLocalOpenCodeServerUrl("http://localhost:4096")); + assert.isTrue(isLocalOpenCodeServerUrl("http://[::1]:4096")); + assert.isFalse(isLocalOpenCodeServerUrl("https://remote.example.com:4096")); + assert.isFalse(isLocalOpenCodeServerUrl("")); + assert.isFalse(isLocalOpenCodeServerUrl("file:///tmp/opencode")); +}); + const serverStarts: Array = []; const reachedServer = (operation: string) => Effect.sync(() => serverStarts.push(operation)).pipe( @@ -70,12 +82,12 @@ const layer = Layer.mergeAll( Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); -const create = (config: Partial, http: HttpClient.HttpClient) => +const create = (config: Partial, http: HttpClient.HttpClient, enabled = true) => OpenCodeDriver.create({ instanceId: ProviderInstanceId.make("opencode-test"), displayName: undefined, environment: [], - enabled: true, + enabled, config: { ...OpenCodeDriver.defaultConfig(), ...config }, }).pipe(Effect.provideService(HttpClient.HttpClient, http)); @@ -187,6 +199,60 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { }).pipe(Effect.scoped), ); + it.effect.each(["2.1.2", null])( + "reports the installed CLI version independently of the loopback server (%s)", + (installedVersion) => + Effect.gen(function* () { + const probeOffline: Array = []; + const runtime = { + ...openCode2Runtime, + runOpenCodeCommand: (input) => + Effect.sync(() => { + probeOffline.push(input.environment?.MISE_OFFLINE); + return { + stdout: + installedVersion === null ? "unrecognized" : `opencode v${installedVersion}\n`, + stderr: "", + code: 0, + }; + }), + } as OpenCodeRuntime.OpenCodeRuntime["Service"]; + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096" }, + replayOpenCodeServer(OPENCODE_2_RESPONSES, ""), + ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); + + const snapshot = yield* instance.snapshot.refresh; + assert.strictEqual(snapshot.version, "2.0.18"); + assert.strictEqual(snapshot.installedVersion, installedVersion ?? undefined); + const maintenance = yield* instance.snapshot.resolveMaintenance({ fresh: true }); + assert.strictEqual(maintenance.packageName, "@opencode/cli"); + assert.isNotEmpty(probeOffline); + assert.isTrue(probeOffline.every((offline) => offline === "1")); + }).pipe(Effect.scoped), + ); + + it.effect("skips the installed CLI probe when a localhost provider is disabled", () => + Effect.gen(function* () { + const probed: string[] = []; + const runtime = { + ...openCode2Runtime, + runOpenCodeCommand: () => + Effect.sync(() => { + probed.push("--version"); + return { stdout: "opencode v2.1.2\n", stderr: "", code: 0 }; + }), + } as OpenCodeRuntime.OpenCodeRuntime["Service"]; + const instance = yield* create({ serverUrl: "http://127.0.0.1:4096" }, noHttp, false).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + ); + + const snapshot = yield* instance.snapshot.refresh; + assert.isFalse(snapshot.enabled); + assert.deepStrictEqual(probed, []); + }).pipe(Effect.scoped), + ); + it.effect("opens a session through OpenCode 2 that a lazy mise shim installs on first use", () => Effect.gen(function* () { serverStarts.length = 0; diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 5c60ccfe43278..fe7118062fd85 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -42,6 +42,7 @@ import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import { makeOpenCodeRuntimeProbe, probeOpenCodeRuntime, + probeOpenCodeBinary, type ProbedOpenCode, } from "./versionProbe.ts"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; @@ -72,6 +73,18 @@ const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); +// A loopback server runs on this environment, but can outlive its CLI installation. +// Its reported version may therefore differ from the installed executable after an update. +export function isLocalOpenCodeServerUrl(serverUrl: string): boolean { + const url = URL.parse(serverUrl.trim()); + if (url?.protocol !== "http:" && url?.protocol !== "https:") return false; + return ( + url.hostname === "localhost" || + url.hostname === "[::1]" || + /^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(url.hostname) + ); +} + function isOpenCodeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -283,7 +296,17 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(Path.Path, pathService), ); const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution( - maintenanceFor(runtimeProbe.get), + maintenanceFor( + isLocalOpenCodeServerUrl(effectiveConfig.serverUrl) + ? probeOpenCodeBinary( + effectiveConfig.binaryPath, + providerProbeEnvironment(processEnv), + ).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + Effect.catch(() => runtimeProbe.get), + ) + : runtimeProbe.get, + ), ); const resolveMaintenance = (options?: { readonly fresh?: boolean }) => !effectiveConfig.enabled @@ -397,6 +420,19 @@ export const OpenCodeDriver: ProviderDriver< ); const checkProvider = Effect.all( { + // A configured loopback server reports its running version. Probe the local + // CLI separately so a completed npm update is not mistaken for a failure + // while that independently managed server still runs the previous binary. + installedVersion: + effectiveConfig.enabled && isLocalOpenCodeServerUrl(effectiveConfig.serverUrl) + ? probeOpenCodeBinary( + effectiveConfig.binaryPath, + providerProbeEnvironment(processEnv), + ).pipe( + Effect.map((probed) => probed.version), + Effect.orElseSucceed(() => undefined), + ) + : Effect.void, provider: checkOpenCodeProviderStatus( effectiveConfig, host.paths.cwd, @@ -410,7 +446,11 @@ export const OpenCodeDriver: ProviderDriver< }, { concurrency: "unbounded" }, ).pipe( - Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), + Effect.map(({ provider, usageLimits, installedVersion }) => ({ + ...provider, + usageLimits, + ...(installedVersion === undefined ? {} : { installedVersion }), + })), Effect.map(stampIdentity), Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), diff --git a/packages/provider-opencode/src/server/versionProbe.ts b/packages/provider-opencode/src/server/versionProbe.ts index 7918958880175..8da0b38f81641 100644 --- a/packages/provider-opencode/src/server/versionProbe.ts +++ b/packages/provider-opencode/src/server/versionProbe.ts @@ -55,7 +55,7 @@ function classifyOpenCodeProbeResponse( return probed(Option.getOrUndefined(version)); } -const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( +export const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( binaryPath: string, environment: NodeJS.ProcessEnv | undefined, ) { From 729f6757d91d9c45614150da297ea7d919bbe81d Mon Sep 17 00:00:00 2001 From: Richard Frost <51714431+rfrost-xyz@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:43:09 +0000 Subject: [PATCH 07/56] fix(usage): Claude subagent output is no longer undercounted (#20276) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../src/provider/Drivers/claudeUsage.ts | 6 +- apps/server/src/usage/UsageService.test.ts | 97 ++++++++++++++++++- apps/server/src/usage/UsageService.ts | 9 +- .../server/src/usage/usageAggregation.test.ts | 67 +++++++++++-- apps/server/src/usage/usageAggregation.ts | 76 +++++++++++++-- apps/server/src/usage/usageScanCache.test.ts | 20 ++-- apps/server/src/usage/usageScanCache.ts | 26 ++--- 7 files changed, 251 insertions(+), 50 deletions(-) diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts index aca7696d31df5..acb697d2941de 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.ts @@ -16,9 +16,9 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; export const CLAUDE_SYNTHETIC_MODEL = ""; /** - * T3 Code writes one record per assistant *content block*, and every one of those records repeats - * the same complete `usage` object for the parent message. Summing them overcounts by roughly - * 2.4x on a real workload, so the caller must drop repeats by `dedupeKey` and keep the first. + * Claude Code repeats a message's `dedupeKey` for each assistant content block. + * Subagent blocks can progress from stream-start to final usage, so callers + * must count each key once using the maximum of each token channel. */ export function parseClaudeLine(line: string): readonly UsageRecord[] { let parsed: unknown; diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index a1de34fac38bb..1c03c05e13727 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -93,6 +93,42 @@ function claudeLine( })}\n`; } +/** One content-block line of message `msg_1`; Claude Code writes several per message. */ +function claudeCopy(usage: Readonly>): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: "req_1", + sessionId: "session-1", + message: { id: "msg_1", model: "claude-fable-5", usage }, + })}\n`; +} + +// A subagent message: its stream-start copy, then its final copy. +const PARTIAL_USAGE = { + input_tokens: 2, + cache_creation_input_tokens: 1097, + cache_read_input_tokens: 67934, + output_tokens: 3, +}; +const COMPLETE_USAGE = { ...PARTIAL_USAGE, output_tokens: 264 }; +const COPY_RATES = { + "claude-fable-5": { + input_cost_per_token: 1e-5, + output_cost_per_token: 5e-5, + cache_read_input_token_cost: 1e-6, + cache_creation_input_token_cost: 1.25e-5, + }, +}; +const COMPLETE_COST_USD = 2 * 1e-5 + 67934 * 1e-6 + 1097 * 1.25e-5 + 264 * 5e-5; +const COMPLETE_TOTALS = { + uncachedInputTokens: 2, + cachedInputTokens: 67934, + cacheCreationTokens: 1097, + outputTokens: 264, + reasoningTokens: 0, +}; + const WINDOW: UsageSummaryInput = { timeZone: "UTC", sinceDay: UsageDay.make("2026-07-31"), @@ -2531,6 +2567,65 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live( + "merges partial Claude copies through tail replacement, newline completion and restart", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const partial = claudeCopy(PARTIAL_USAGE); + const complete = claudeCopy(COMPLETE_USAGE); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, partial)); + yield* Effect.gen(function* () { + const first = yield* UsageService.make; + assert.strictEqual(totalOutputTokens(yield* first.readSummary(WINDOW)), 3); + yield* first.awaitPersisted; + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, complete.trimEnd())); + const resumed = yield* UsageService.make; + const tail = yield* resumed.readSummary(WINDOW); + assert.deepStrictEqual(tail.buckets[0]?.totals, COMPLETE_TOTALS); + assert.strictEqual(tail.buckets[0]?.records, 1); + assert.closeTo(tail.buckets[0]?.costUsd ?? 0, COMPLETE_COST_USD, 1e-12); + yield* resumed.awaitPersisted; + const restarted = yield* UsageService.make; + assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, tail.buckets); + + // Grow the file without changing its settled prefix: the old tail must be discarded. + yield* Effect.promise(() => + NodeFSP.writeFile(transcript, partial + claudeLine(2, 7) + claudeLine(3, 5)), + ); + const replaced = yield* restarted.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(replaced), 3 + 7 + 5); + assert.strictEqual(replaced.buckets[0]?.records, 3); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, complete.trimEnd())); + const grown = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(grown.buckets[0]?.totals, { + ...COMPLETE_TOTALS, + uncachedInputTokens: 22, + outputTokens: 264 + 7 + 5, + }); + assert.strictEqual(grown.buckets[0]?.records, 3); + yield* Effect.promise(() => NodeFSP.appendFile(transcript, "\n")); + assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, grown.buckets); + yield* restarted.awaitPersisted; + assert.deepStrictEqual( + (yield* (yield* UsageService.make).readSummary(WINDOW)).buckets, + grown.buckets, + ); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-copy-lifecycle-test", + home, + settings, + ratesDocument: COPY_RATES, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live( "keeps large-record totals and costs exact through append, dedupe, restart and cleanup", () => @@ -2687,7 +2782,7 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v11.json"); + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v12.json"); assert.deepStrictEqual( (yield* Effect.promise(() => NodeFSP.readdir(stateDir))) .filter((name) => name.startsWith("usage-scan-cache")) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index b834b078058a0..771393dac2579 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -473,7 +473,7 @@ export const make = Effect.gen(function* () { /** * Reads run concurrently, and the caller stores updates in walk order rather * than completion order: saved records of deleted transcripts aggregate in - * cache order, where the first copy of a duplicate wins. Untraced: a scan + * cache order, where the first copy of a duplicate decides its bucket. Untraced: a scan * reads thousands of files, so scanTranscriptDir records the counts instead. */ const readFileRecords = Effect.fnUntraced(function* ( @@ -551,9 +551,8 @@ export const make = Effect.gen(function* () { } } 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); + const records = dedupeWithinFile([...base, ...parsed.records]); + const tailRecords = parsed.tailRecords; return { records: tailRecords.length === 0 ? records : [...records, ...tailRecords], @@ -686,7 +685,7 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, ); - // Scan independent sources together, directories sequentially to bound open files; preserve order for first-copy dedupe. + // Scan independent sources together, directories sequentially to bound open files; keep order, since the first copy decides a duplicate's bucket. const [transcripts, scanDirs] = yield* Effect.all( [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans], { concurrency: "unbounded" }, diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 08b3f3ae1b8c8..7e4e0ea689cdf 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -85,17 +85,68 @@ describe("UsageAggregator", () => { expect(bucket).not.toHaveProperty("ultrafastCostUsd"); }); - it("keeps only the first record for a repeated dedupe key", () => { + it("merges partial copies of a key by channel maximum in the first copy's bucket", () => { + const zeroTotals = { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + const partial = record({ + dedupeKey: "msg_1:", + speed: "fast", + reportedCostUsd: 1, + totals: { ...record().totals, cachedInputTokens: 1200, outputTokens: 3, reasoningTokens: 5 }, + }); + const complete = record({ + dedupeKey: "msg_1:", + model: "claude-fable-5[1m]", + timestampMs: Date.parse("2026-08-08T12:00:00Z"), + sessionId: "session-fork", + reportedCostUsd: 2, + totals: { + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 40, + outputTokens: 264, + reasoningTokens: 135, + }, + }); + const zero = { ...partial, totals: zeroTotals, reportedCostUsd: null }; + const merged = { + reportedCostUsd: 2, + totals: { ...complete.totals, uncachedInputTokens: 100, cachedInputTokens: 1200 }, + }; + + for (const copies of [ + [zero, partial, complete, complete], + [complete, partial, zero, zero], + ]) { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + copies.forEach((copy, index) => aggregator.add(copy, `${index}.jsonl`)); + const result = aggregator.finish(); + const expected = aggregate([{ ...copies[0]!, ...merged }]).buckets[0]; + + expect(result.duplicatesDropped).toBe(3); + expect(result.buckets).toEqual([{ ...expected, sourcePath: "0.jsonl" }]); + } + }); + + it("does not count a key whose first copy fell outside the window", () => { const result = aggregate([ - record({ dedupeKey: "msg_1:" }), - record({ dedupeKey: "msg_1:" }), - record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:", timestampMs: Date.parse("2026-07-01T12:00:00Z") }), + record({ dedupeKey: "msg_1:", totals: { ...record().totals, outputTokens: 500 } }), ]); - expect(result.duplicatesDropped).toBe(2); - expect(result.buckets).toHaveLength(1); - expect(result.buckets[0]?.records).toBe(1); - expect(result.buckets[0]?.totals.outputTokens).toBe(50); + expect(result.duplicatesDropped).toBe(1); + expect(result.outOfWindow).toBe(1); + expect(result.buckets).toHaveLength(0); }); it("buckets by the day in the requested time zone", () => { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 8f94c84a3c470..0bdf1b8cc2612 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -129,10 +129,50 @@ export interface AggregateResult { readonly outOfWindow: number; } +/** + * Partial copies share a billed message's key: keep each token channel's maximum and the larger + * reported cost, and the first copy's time, model, session and speed so it stays in its bucket. + */ +export function mergeUsageCopies(held: UsageRecord, copy: UsageRecord): UsageRecord { + const a = held.totals; + const b = copy.totals; + const reportedCostUsd = + held.reportedCostUsd === null || copy.reportedCostUsd === null + ? (held.reportedCostUsd ?? copy.reportedCostUsd) + : Math.max(held.reportedCostUsd, copy.reportedCostUsd); + if ( + b.uncachedInputTokens <= a.uncachedInputTokens && + b.cachedInputTokens <= a.cachedInputTokens && + b.cacheCreationTokens <= a.cacheCreationTokens && + b.outputTokens <= a.outputTokens && + b.reasoningTokens <= a.reasoningTokens && + reportedCostUsd === held.reportedCostUsd + ) { + return held; + } + return { + ...held, + totals: { + uncachedInputTokens: Math.max(a.uncachedInputTokens, b.uncachedInputTokens), + cachedInputTokens: Math.max(a.cachedInputTokens, b.cachedInputTokens), + cacheCreationTokens: Math.max(a.cacheCreationTokens, b.cacheCreationTokens), + outputTokens: Math.max(a.outputTokens, b.outputTokens), + reasoningTokens: Math.max(a.reasoningTokens, b.reasoningTokens), + }, + reportedCostUsd, + }; +} + +interface Contribution { + record: UsageRecord; + /** Null when the first copy fell outside the window. */ + readonly bucket: MutableBucket | null; +} + /** Deduplicates across the whole scan; Claude copies records into resumed/forked transcripts. */ export class UsageAggregator { readonly #buckets = new Map(); - readonly #seen = new Set(); + readonly #seen = new Map(); readonly #toDay: (timestampMs: number) => string; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; @@ -167,20 +207,33 @@ export class UsageAggregator { if (!keepsUsageRecord(this.#options.modelFilter, input)) return false; const record = this.#mapModel(input); if (record.dedupeKey !== null) { - if (this.#seen.has(record.dedupeKey)) { + const held = this.#seen.get(record.dedupeKey); + if (held !== undefined) { this.#duplicatesDropped += 1; + if (held.bucket !== null) held.record = mergeUsageCopies(held.record, record); return false; } - this.#seen.add(record.dedupeKey); } + const bucket = this.#placeInWindow(record, sourcePath ?? ""); + if (record.dedupeKey !== null) this.#seen.set(record.dedupeKey, { record, bucket }); + if (bucket === null) { + this.#outOfWindow += 1; + return false; + } + // Keyed usage is counted in finish(), once all its copies are merged. + if (record.dedupeKey === null) this.#accumulate(bucket, record); + if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); + return true; + } + + #placeInWindow(record: UsageRecord, source: string): MutableBucket | null { if ( this.#hourlyWindow !== null && (record.timestampMs < this.#hourlyWindow.sinceTimeMs || record.timestampMs >= this.#hourlyWindow.untilTimeMs) ) { - this.#outOfWindow += 1; - return false; + return null; } const day = this.#toDay(record.timestampMs); @@ -188,16 +241,17 @@ export class UsageAggregator { this.#hourlyWindow === null && (day < this.#options.sinceDay || day > this.#options.untilDay) ) { - this.#outOfWindow += 1; - return false; + return null; } const hourIndex = this.#hourlyWindow === null ? -1 : Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS); - const bucket = this.#bucketFor(day, hourIndex, record.provider, record.model, sourcePath ?? ""); + return this.#bucketFor(day, hourIndex, record.provider, record.model, source); + } + #accumulate(bucket: MutableBucket, record: UsageRecord): void { const priced = priceUsage(this.#options.rates, record, this.#options.priceOverrides); const totals = bucket.totals; @@ -231,8 +285,6 @@ export class UsageAggregator { bucket.records += 1; if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; - if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); - return true; } // Aliases use the target's rate, replacing provider-specific rateModel. @@ -288,6 +340,10 @@ export class UsageAggregator { } finish(): AggregateResult { + for (const { record, bucket } of this.#seen.values()) { + if (bucket !== null) this.#accumulate(bucket, record); + } + this.#seen.clear(); const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index d0e5eaceec269..2866a295da8fe 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -217,15 +217,11 @@ describe("scan cache round trip", () => { }; const restored = decodeScanCache(JSON.parse(JSON.stringify(legacy)), TEST_FORMAT_MAP); - expect(restored.get("/gone.jsonl")).toEqual( - version >= 10 - ? original.get("/gone.jsonl") - : { - ...original.get("/gone.jsonl"), - size: -1, - position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, - }, - ); + expect(restored.get("/gone.jsonl")).toEqual({ + ...original.get("/gone.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); expect(restored.get("/gone-codex.jsonl")).toEqual({ ...original.get("/gone-codex.jsonl"), size: -1, @@ -248,11 +244,11 @@ describe("scan cache round trip", () => { }, ); - it("re-parses a Claude transcript cached before advisor usage was counted", () => { + it("re-parses a Claude transcript cached before partial copies were merged", () => { const original = cacheWith([["/claude.jsonl", 100, [record()]]]); - const v9 = { ...parse(makeScanCacheWriter()(original, {})), version: 9 }; + const v11 = { ...parse(makeScanCacheWriter()(original, {})), version: 11 }; - const restored = decodeScanCache(JSON.parse(JSON.stringify(v9)), TEST_FORMAT_MAP); + const restored = decodeScanCache(JSON.parse(JSON.stringify(v11)), TEST_FORMAT_MAP); expect(restored.get("/claude.jsonl")).toEqual({ ...original.get("/claude.jsonl"), size: -1, diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index c2cdc1cbf5abd..4cd959c75aa25 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -7,9 +7,10 @@ import type { import * as Schema from "effect/Schema"; import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; +import { mergeUsageCopies } from "./usageAggregation.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 11 as const; +const USAGE_SCAN_CACHE_VERSION = 12 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -17,8 +18,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * replace it, dropping saved usage for deleted transcripts. Separate files keep * the newer one; older files are removed once it is saved. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v11.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v12.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v11.json", "usage-scan-cache-v10.json", "usage-scan-cache-v9.json", "usage-scan-cache-v8.json", @@ -289,9 +291,9 @@ export function decodeScanCache( } // Keep history for deleted rollouts, but re-parse incompatible reducer state. // v9 and v11 repair skipped Codex requests; clean v8+ entries stay served while unchanged. - // v10 adds Claude advisor calls, which no older Claude entry holds. + // v10 adds Claude advisor calls; before v12 a repeated Claude key kept its first, possibly partial, copy. const legacy = - (entry.p === "claude" && version < 10) || + (entry.p === "claude" && version < 12) || (format.state !== undefined && (version < 6 || (entry.p === "codex" && (version < 8 || (version < 11 && malformedRecords > 0))))); @@ -353,16 +355,18 @@ export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): num return removed; } -/** Mutates seen; share it across incremental line/tail batches to deduplicate the whole file. */ -export function dedupeWithinFile( - records: readonly UsageRecord[], - seen: Set = new Set(), -): readonly UsageRecord[] { +/** Pass only newline-terminated records: an unterminated tail can still change, so the aggregator merges it per scan. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { const kept: UsageRecord[] = []; + const firstIndex = new Map(); for (const record of records) { if (record.dedupeKey !== null) { - if (seen.has(record.dedupeKey)) continue; - seen.add(record.dedupeKey); + const index = firstIndex.get(record.dedupeKey); + if (index !== undefined) { + kept[index] = mergeUsageCopies(kept[index]!, record); + continue; + } + firstIndex.set(record.dedupeKey, kept.length); } kept.push(record); } From d0d0608b6be8c50cdc38b5045de76c5d0b9407cb Mon Sep 17 00:00:00 2001 From: Ibrahim Elkamali Date: Wed, 11 Nov 2026 12:48:22 +0300 Subject: [PATCH 08/56] fix(server): a remote update points the service unit at the runtime it committed (#19652) Co-authored-by: Julius Marminge --- apps/server/src/cloud/bootService.test.ts | 194 ++++++++++++ apps/server/src/cloud/bootService.ts | 186 ++++++------ apps/server/src/cloud/serviceProtocol.ts | 38 ++- .../src/cloud/serviceSelectionLock.test.ts | 30 ++ apps/server/src/cloud/serviceSelectionLock.ts | 88 ++++++ apps/server/src/serviceLauncher.test.ts | 284 ++++++++++++++++-- apps/server/src/serviceLauncher.ts | 216 +++++++++++-- docs/internals/server-updates.md | 24 +- docs/user/updating.md | 5 + 9 files changed, 937 insertions(+), 128 deletions(-) create mode 100644 apps/server/src/cloud/serviceSelectionLock.test.ts create mode 100644 apps/server/src/cloud/serviceSelectionLock.ts diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 814d470cad410..841aa78f6c879 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Cross-process launcher contention uses real filesystem leases and IPC barriers. +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -5,11 +7,13 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Duration from "effect/Duration"; 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 Path from "effect/Path"; import { CliError } from "effect/cli"; import { HttpClient } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { vi } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; import * as BootService from "./bootService.ts"; @@ -22,6 +26,24 @@ import { serviceStateHasPendingUpdate, } from "./serviceProtocol.ts"; +const leaseObserver = vi.hoisted(() => ({ contended: (_destination: string) => {} })); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renameSync: (...args: Parameters) => { + try { + return actual.renameSync(...args); + } catch (error) { + if (["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) { + leaseObserver.contended(String(args[1])); + } + throw error; + } + }, + }; +}); + it("reads the served T3 home back out of a rendered unit or plist", () => { const plan = (baseDir: string) => ({ program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], @@ -442,6 +464,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { current: false, installedVersion: "1.2.4-nightly.1", }); + yield* service.install({ allowDowngrade: true }); + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.3", + }); } }), ); @@ -575,6 +602,168 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(yield* newer.restart).toBe("restarted"); expect((yield* newer.status).problems).not.toContain("restart-pending"); expect((yield* newer.status).current).toBe(true); + + const latest = yield* makeService(undefined, "1.2.5"); + yield* latest.install({ start: true }); + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.5", + }); + expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.5/t3"); + expect((yield* latest.status).current).toBe(true); + expect( + yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)), + ).toBe(false); + }), + ); + + it.effect( + "serializes a local install with another process reconciling an older unit snapshot", + () => + Effect.gen(function* () { + const { service, fs, statePath, makeService, runtime } = yield* makeHarness(); + const path = yield* Path.Path; + const plan = yield* service.install(); + const unit = yield* fs.readFileString(plan.unitPath); + yield* fs.writeFileString( + plan.unitPath, + unit.replace("versions/1.2.3/t3", "versions/1.2.2/t3"), + ); + yield* fs.writeFileString( + statePath, + JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.3", + update: { + id: "committed", + fromVersion: "1.2.2", + targetVersion: "1.2.3", + status: "committed", + }, + }), + ); + const rejection = path.join(plan.baseDir, "rejected-update"); + const databasePath = path.join(plan.baseDir, "state.sqlite"); + yield* fs.writeFileString(databasePath, "database"); + yield* fs.writeFileString( + runtime.entryPath, + `#!${process.execPath} +import { writeFileSync } from "node:fs"; +process.on("message", message => { + if (message === "request-update") process.send({ type: "request-update", targetVersion: "1.2.5", dbPath: ${JSON.stringify(databasePath)} }); + if (message.type === "update-rejected") { + writeFileSync(${JSON.stringify(rejection)}, message.reason); + process.exit(0); + } +}); +`, + ); + yield* fs.chmod(runtime.entryPath, 0o755); + const newer = yield* makeService(undefined, "1.2.4"); + yield* makeService(undefined, "1.2.5"); + const snapshot = Promise.withResolvers(); + const milestone = Promise.withResolvers(); + const script = path.join(plan.baseDir, "launcher-contention.mjs"); + yield* fs.writeFileString( + script, + ` +import fs from "node:fs/promises"; +import cp from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +const server = Promise.withResolvers(); +const originalSpawn = cp.spawn; +cp.spawn = (...args) => { const child = originalSpawn(...args); server.resolve(child); return child; }; +process.on("message", async message => { + if (message === "installed") (await server.promise).send("request-update"); +}); +const originalRead = fs.readFile; +fs.readFile = async (...args) => { + const contents = await originalRead(...args); + if (args[0] === ${JSON.stringify(plan.unitPath)}) { + fs.readFile = originalRead; + syncBuiltinESMExports(); + const resume = new Promise(resolve => process.once("message", resolve)); + process.send("snapshot"); + await resume; + } + return contents; +}; +syncBuiltinESMExports(); +const { Launcher, readServiceState } = await import(${JSON.stringify(import.meta.resolve("../serviceLauncher.ts"))}); +const launcher = new Launcher(${JSON.stringify(plan.baseDir)}, await readServiceState(${JSON.stringify(statePath)}), { + serviceUnit: { path: ${JSON.stringify(plan.unitPath)}, reload: async () => {} }, +}); +try { await launcher.run(); throw Error("unexpected completion"); } +catch (error) { + if (!error.message.includes("Active child exited unexpectedly (0)")) throw error; +} +const { acquireServiceSelectionLock } = await import(${JSON.stringify(import.meta.resolve("./serviceSelectionLock.ts"))}); +// Exit owning the real lease, as a crashed process would. +await acquireServiceSelectionLock(${JSON.stringify(plan.unitPath)}); +process.disconnect(); +`, + ); + const child = yield* Effect.acquireRelease( + Effect.sync(() => + NodeChildProcess.fork(script, [], { + execArgv: [], + stdio: ["ignore", "inherit", "inherit", "ipc"], + }), + ), + (child) => + Effect.sync(() => { + if (child.exitCode === null && child.signalCode === null) child.kill(); + }), + ); + const exited = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => + code === 0 ? resolve() : reject(new Error(`launcher exited ${code}`)), + ); + }); + child.on("message", (message) => { + if (message === "snapshot") snapshot.resolve(); + }); + yield* Effect.promise(() => + Promise.race([ + snapshot.promise, + exited.then(() => { + throw new Error("launcher exited before snapshot"); + }), + ]), + ); + leaseObserver.contended = (destination) => { + if (destination === plan.unitPath + ".selection-lock") milestone.resolve(); + }; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + leaseObserver.contended = () => {}; + }), + ); + const installing = yield* newer + .install({ start: false }) + .pipe(Effect.ensuring(Effect.sync(() => milestone.resolve())), Effect.forkChild); + // Resume the launcher once the installer contends for the unit lease, or once an + // uncoordinated installer has finished, which makes the stale snapshot win. + yield* Effect.promise(() => milestone.promise); + child.send("resume"); + yield* Fiber.join(installing); + child.send("installed"); + yield* Effect.promise(() => exited); + expect(yield* fs.readFileString(rejection)).toContain("Service selection changed"); + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.4", + }); + expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); + expect( + yield* fs.readFileString( + path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE), + ), + ).toBe("1.2.4\n"); + // The next installer reclaims the dead owner and releases its own lease. + yield* newer.install({ start: false }); + expect(yield* fs.exists(plan.unitPath + ".selection-lock")).toBe(false); }), ); @@ -617,6 +806,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const error = yield* service.install({ start: false }).pipe(Effect.flip); expect(error._tag).toBe("BootServiceUpdatePendingError"); expect(yield* fs.readFileString(statePath)).toBe(pendingState); + yield* fs.remove((yield* service.status).unitPath); + expect((yield* service.install({ start: false }).pipe(Effect.flip))._tag).toBe( + "BootServiceUpdatePendingError", + ); + expect(yield* fs.readFileString(statePath)).toBe(pendingState); }), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 88d477974ae0a..e7de29ad8953d 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -16,6 +16,7 @@ import * as Schema from "effect/Schema"; import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; import * as ProcessRunner from "../processRunner.ts"; +import { acquireServiceSelectionLock } from "./serviceSelectionLock.ts"; import { ensurePinnedRuntimeInstalled, launcherOwnsVersionsDir, @@ -28,20 +29,26 @@ import { SERVICE_LAUNCHER_PROTOCOL, SERVICE_RESTART_PENDING_FILE, SERVICE_STATE_FILE, + SERVICE_UNIT_ENV, compareExactServiceVersions, + serviceUnitBaseDirOf as bootServiceBaseDirOf, parseServiceState, serviceStateActiveVersion, serviceStateHasPendingUpdate, type ServiceState, } from "./serviceProtocol.ts"; +export { + serviceUnitBaseDirOf as bootServiceBaseDirOf, + serviceUnitProgramOf as bootServiceProgramOf, +} from "./serviceProtocol.ts"; + const BOOT_SERVICE_NAME = "t3code"; export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; // `.service` suffix keeps the label distinct from the desktop app's bundle id // (com.t3tools.t3code), so launchd and TCC records never collide. export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; -const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; export const BOOT_SERVICE_LOG_FILE = "boot-service.log"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ @@ -56,32 +63,6 @@ function quoteSystemdValue(value: string): string { : escaped; } -const unquoteSystemdValue = (raw: string) => - (raw.startsWith('"') && raw.endsWith('"') - ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") - : raw - ).replaceAll("%%", "%"); - -const unescapeXmlText = (value: string) => - value.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); - -export function bootServiceBaseDirOf(contents: string): string | undefined { - const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1]; - if (systemd !== undefined) return unquoteSystemdValue(systemd.trim()); - const plist = /T3CODE_HOME<\/key>\s*([^<]*)<\/string>/.exec(contents)?.[1]; - return plist === undefined ? undefined : unescapeXmlText(plist); -} - -/** The executable an installed unit or plist starts: the launcher's runtime. */ -export function bootServiceProgramOf(contents: string): string | undefined { - const systemd = /^ExecStart=("(?:\\.|[^"\\])*"|\S+)/m.exec(contents)?.[1]; - if (systemd !== undefined) return unquoteSystemdValue(systemd); - const plist = /ProgramArguments<\/key>\s*\s*([^<]*)<\/string>/.exec( - contents, - )?.[1]; - return plist === undefined ? undefined : unescapeXmlText(plist); -} - export interface BootServicePlan { readonly program: ReadonlyArray; readonly baseDir: string; @@ -104,7 +85,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, - `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, + `Environment=${SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${plan.program.map(quoteSystemdValue).join(" ")}`, // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. @@ -162,7 +143,7 @@ export function renderBootServicePlist( ` ${escapeXmlText(options.environmentPath)}`, ` T3CODE_HOME`, ` ${escapeXmlText(plan.baseDir)}`, - ` ${BOOT_SERVICE_UNIT_ENV}`, + ` ${SERVICE_UNIT_ENV}`, ` ${BOOT_SERVICE_PLIST_FILE}`, ` `, ` WorkingDirectory`, @@ -496,9 +477,12 @@ export class BootServicePrerequisiteError extends Schema.TaggedError()( "BootServiceUpdatePendingError", - {}, + { recoveryCommand: Schema.optional(Schema.String) }, ) { override get message(): string { + if (this.recoveryCommand !== undefined) { + return `A remote server update is still pending, but its service definition is missing. With the service stopped, run this on the server machine: ${this.recoveryCommand}\nWait for the update to finish or roll back, stop the foreground launcher with Ctrl-C, then retry the install.`; + } return "A remote server update is still pending. Wait for it to finish, then retry."; } } @@ -606,7 +590,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const xdgConfigHome = yield* Config.String("XDG_CONFIG_HOME").pipe(Config.withDefault("")); const installerPath = yield* Config.String("PATH").pipe(Config.withDefault("")); const runningInsideService = - (yield* Config.String(BOOT_SERVICE_UNIT_ENV).pipe(Config.withDefault(""))) === + (yield* Config.String(SERVICE_UNIT_ENV).pipe(Config.withDefault(""))) === BOOT_SERVICE_UNIT_FILE; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -799,6 +783,16 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (remaining[0]) return yield* new BootServicePrerequisiteError({ problem: remaining[0] }); }); + const withSelectionLock = (selectionPath: string, operation: Effect.Effect) => + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => acquireServiceSelectionLock(selectionPath), + catch: (cause) => new BootServiceInstallError({ cause }), + }), + () => operation, + (release) => Effect.sync(release), + ); + const install = Effect.fn("cloud.boot_service.install")(function* (options?: { readonly allowDowngrade?: boolean; readonly start?: boolean; @@ -876,8 +870,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const installed = yield* fs .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - // With start=false, changing startup-only files is safe except during a remote update, - // when the launcher also writes its state file. + // Stop outside the selection lock: the launcher may be waiting to acquire it. const start = options?.start !== false; if (installed && start && runningInsideService && manager.kind === "systemd") { // Stopping the unit from inside it kills this process before the start. @@ -890,7 +883,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // Transient properties reach systemd literally, unlike unit-file specifiers. `--property=StandardOutput=append:${logPath}`, `--property=StandardError=append:${logPath}`, - `--setenv=${BOOT_SERVICE_UNIT_ENV}=`, + `--setenv=${SERVICE_UNIT_ENV}=`, runtimePaths.entryPath, "service", "install", @@ -905,58 +898,83 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* runSteps(manager.stop); } - yield* Effect.gen(function* () { - if (installed) { - const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); - if (Option.isSome(previousStateText)) { - if (serviceStateHasPendingUpdate(previousStateText.value)) { - return yield* new BootServiceUpdatePendingError(); + // State first, then unit. The launcher acquires only one at a time, and a + // failure to lock the unit must never prevent its durable update commit. + yield* withSelectionLock( + statePath, + withSelectionLock( + unitPath, + Effect.gen(function* () { + const currentlyInstalled = yield* fs.exists(unitPath); + const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); + if (Option.isSome(previousStateText)) { + const installedVersion = serviceStateActiveVersion(previousStateText.value); + if (serviceStateHasPendingUpdate(previousStateText.value)) { + const quote = (word: string) => `'${word.replaceAll("'", "'\\''")}'`; + return yield* new BootServiceUpdatePendingError( + !currentlyInstalled && installedVersion !== undefined + ? { + recoveryCommand: `T3CODE_HOME=${quote(input.baseDir)} ${quote(pinnedRuntimePaths(path, input.baseDir, installedVersion, platform).entryPath)} __service-launcher`, + } + : {}, + ); + } + // Read the final version after stopping the launcher; a remote update may finish after the status check. + if ( + installedVersion !== undefined && + options?.allowDowngrade !== true && + compareExactServiceVersions(input.cliVersion, installedVersion) < 0 + ) { + return yield* new BootServiceDowngradeRefusedError({ + installedVersion, + targetVersion: input.cliVersion, + }); + } } - // Read the final version after stopping the launcher; a remote update may finish after the status check. - 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 + .makeDirectory(path.dirname(unitPath), { recursive: true }) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + // Files can match the new version even when activation fails; keep that install retryable. + yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); + yield* writeDurably( + statePath, + // @effect-diagnostics-next-line globalJsonInEffect:off - fixed launcher-owned document. + `${JSON.stringify( + { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: input.cliVersion, + } satisfies ServiceState, + null, + 2, + )}\n`, + ); + if (!start && (currentlyInstalled || Option.isSome(previousStateText))) { + const written = yield* fs.readFileString(statePath); + if (serviceStateActiveVersion(written) !== input.cliVersion) { + return yield* new BootServiceUpdatePendingError(); + } } - } - } - yield* fs - .makeDirectory(path.dirname(unitPath), { recursive: true }) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - // Files can match the new version even when activation fails; keep that install retryable. - yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); - yield* writeDurably( - statePath, - // @effect-diagnostics-next-line globalJsonInEffect:off - fixed launcher-owned document. - `${JSON.stringify( - { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: input.cliVersion, - } satisfies ServiceState, - null, - 2, - )}\n`, - ); - if (!start && installed) { - // A concurrent remote update can overwrite launcher state; detect it before repointing the unit. - const written = yield* fs.readFileString(statePath); - if (serviceStateActiveVersion(written) !== input.cliVersion) { - return yield* new BootServiceUpdatePendingError(); - } - } - yield* writeDurably(unitPath, manager.render(plan)); - - if (start) { - yield* runSteps(manager.activate); - yield* fs.remove(restartPendingPath, { force: true }); - } - }).pipe( + yield* writeDurably(unitPath, manager.render(plan)); + }), + ), + ).pipe( + Effect.andThen( + Effect.gen(function* () { + if (start) { + yield* runSteps(manager.activate); + yield* withSelectionLock( + statePath, + Effect.gen(function* () { + // A concurrent deferred install may now be waiting for a newer restart. + const selected = parseServiceState(yield* fs.readFileString(statePath)); + if (selected?.activeVersion === input.cliVersion && selected.update === undefined) { + yield* fs.remove(restartPendingPath, { force: true }); + } + }), + ); + } + }), + ), Effect.mapError((cause) => cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, ), diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index 3b8ea548d93da..8958c945aab84 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -1,15 +1,46 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; -// Bump for incompatible runtime layout changes; launchers survive self-updates. Protocol 3 requires standalone executables. -export const SERVICE_LAUNCHER_PROTOCOL = 3 as const; +// Protocol 4 requires coordinated selection writes and unit reconciliation. An +// installed protocol-3 launcher needs one local update or service install with a restart: +// remote updates replace its child, so cannot add these launcher guarantees. +export const SERVICE_LAUNCHER_PROTOCOL = 4 as const; export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; export const SERVICE_ENV_FILE = "service.env"; export const SERVICE_STATE_FILE = "service-state.json"; +/** Set by the unit or launch agent to its own file name, so the launcher can + find the definition that started it. */ +export const SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; /** Written before explicit stop to distinguish shutdown from update handoff. */ export const SERVICE_STOP_MARKER_FILE = ".service-stopping"; /** Marks a repointed unit whose service still runs the old version; launcher startup clears it. */ export const SERVICE_RESTART_PENDING_FILE = ".restart-pending"; +const unquoteSystemdValue = (raw: string) => + (raw.startsWith('"') && raw.endsWith('"') + ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") + : raw + ).replaceAll("%%", "%"); + +const unescapeXmlText = (value: string) => + value.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); + +export function serviceUnitBaseDirOf(contents: string): string | undefined { + const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1]; + if (systemd !== undefined) return unquoteSystemdValue(systemd.trim()); + const plist = /T3CODE_HOME<\/key>\s*([^<]*)<\/string>/.exec(contents)?.[1]; + return plist === undefined ? undefined : unescapeXmlText(plist); +} + +/** The executable an installed unit or plist starts: the launcher's runtime. */ +export function serviceUnitProgramOf(contents: string): string | undefined { + const systemd = /^ExecStart=("(?:\\.|[^"\\])*"|\S+)/m.exec(contents)?.[1]; + if (systemd !== undefined) return unquoteSystemdValue(systemd); + const plist = /ProgramArguments<\/key>\s*\s*([^<]*)<\/string>/.exec( + contents, + )?.[1]; + return plist === undefined ? undefined : unescapeXmlText(plist); +} + export interface PendingServiceUpdate { readonly id: string; readonly fromVersion: string; @@ -143,7 +174,8 @@ export function decodeServiceState(value: unknown): ServiceState | undefined { if (!isRecord(value)) return undefined; const update = value.update === undefined ? undefined : decodeServiceUpdate(value.update); if ( - value.protocol !== SERVICE_LAUNCHER_PROTOCOL || + // Protocol 3 has the same durable format; its installer can select a protocol-4 runtime. + (value.protocol !== 3 && value.protocol !== SERVICE_LAUNCHER_PROTOCOL) || typeof value.activeVersion !== "string" || !isExactServiceVersion(value.activeVersion) || (value.update !== undefined && update === undefined) || diff --git a/apps/server/src/cloud/serviceSelectionLock.test.ts b/apps/server/src/cloud/serviceSelectionLock.test.ts new file mode 100644 index 0000000000000..d2567de4c0bc5 --- /dev/null +++ b/apps/server/src/cloud/serviceSelectionLock.test.ts @@ -0,0 +1,30 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { acquireServiceSelectionLock } from "./serviceSelectionLock.ts"; + +it.layer(NodeServices.layer)("service selection lock", (it) => { + it.effect("reclaims a lease from an earlier boot even when its PID is live again", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-selection-lock-" }); + const selectionPath = path.join(root, "service-state.json"); + const lock = `${selectionPath}.selection-lock`; + const stale = `${process.pid}.1.00000000-0000-4000-8000-000000000000`; + yield* fs.makeDirectory(lock, { recursive: true }); + yield* fs.writeFileString(path.join(lock, stale), ""); + + const release = yield* Effect.promise(() => acquireServiceSelectionLock(selectionPath)); + const owners = yield* fs.readDirectory(lock); + assert.equal(owners.length, 1); + assert.notEqual(owners[0], stale); + assert.isTrue(owners[0]!.startsWith(`${process.pid}.`)); + release(); + assert.isFalse(yield* fs.exists(lock)); + }), + ); +}); diff --git a/apps/server/src/cloud/serviceSelectionLock.ts b/apps/server/src/cloud/serviceSelectionLock.ts new file mode 100644 index 0000000000000..4cbd123c22f7e --- /dev/null +++ b/apps/server/src/cloud/serviceSelectionLock.ts @@ -0,0 +1,88 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Shared with the standalone launcher, which has no Effect runtime. +// @effect-diagnostics globalTimers:off -- Bounded cross-process lease acquisition in the standalone launcher. +// @effect-diagnostics globalDate:off -- The standalone launcher has no Effect clock for its acquisition deadline. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +// Coarse boot time: a lease from an earlier boot is dead even when its PID is live again. +const bootEpochSeconds = () => Math.round(Date.now() / 1000 - NodeOS.uptime()); + +/** Serialize durable selection and unit writes, never manager stop/start commands. */ +export async function acquireServiceSelectionLock(selectionPath: string): Promise<() => void> { + const directory = NodePath.dirname(selectionPath); + NodeFS.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const lock = selectionPath + ".selection-lock"; + const owner = `${process.pid}.${bootEpochSeconds()}.${NodeCrypto.randomUUID()}`; + const candidate = `${lock}.${owner}`; + const deadline = Date.now() + 30_000; + const removeEmptyLock = () => { + try { + NodeFS.rmdirSync(lock); + } catch (error) { + if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) + throw error; + } + }; + NodeFS.mkdirSync(candidate, { mode: 0o700 }); + try { + NodeFS.writeFileSync(NodePath.join(candidate, owner), "", { mode: 0o600 }); + while (true) { + try { + // Publish the owner atomically; rename cannot replace another populated directory. + NodeFS.renameSync(candidate, lock); + return () => { + NodeFS.unlinkSync(NodePath.join(lock, owner)); + removeEmptyLock(); + }; + } catch (error) { + if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) + throw error; + } + let owners: string[]; + try { + owners = NodeFS.readdirSync(lock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + if (owners.length === 1) { + const previous = owners[0]!; + const [pidText, bootText] = previous.split("."); + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) + throw new Error(`Invalid service selection lock owner at ${lock}.`); + const ownerBoot = Number(bootText); + const fromEarlierBoot = + Number.isSafeInteger(ownerBoot) && Math.abs(ownerBoot - bootEpochSeconds()) > 60; + let dead = fromEarlierBoot; + if (!dead) { + try { + process.kill(pid, 0); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ESRCH" && code !== "EPERM") throw error; + dead = code === "ESRCH"; + } + } + if (dead) { + // Remove only this dead owner's unique file, never a replacement owner's. + try { + NodeFS.unlinkSync(NodePath.join(lock, previous)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + } + removeEmptyLock(); + if (Date.now() >= deadline) + throw new Error( + `Service selection is locked at ${lock}. Retry when the other operation finishes.`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } finally { + NodeFS.rmSync(candidate, { recursive: true, force: true }); + } +} diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 0006d371cf2c9..3304211d9e7c6 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -2,6 +2,8 @@ import type * as NodeChildProcess from "node:child_process"; // @effect-diagnostics-next-line nodeBuiltinImport:off - the free-space probe is a plain Promise callback. import * as NodeFS from "node:fs"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - models the service manager reading disk through the launcher's Promise callback. +import * as NodeFSP from "node:fs/promises"; import * as NodeSqlite from "node:sqlite"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -13,10 +15,10 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { vi } from "vite-plus/test"; +import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts"; import { databaseBackupBlocker, Launcher, - main, readServiceEnvironment, type BackupSpaceProbe, readServiceState, @@ -112,6 +114,13 @@ it("rejects contradictory service state", () => { ); }); +const unitPlan = (root: string, version: string) => ({ + program: [`${root}/runtime/versions/${version}/t3`, "__service-launcher"], + baseDir: root, + logPath: `${root}/logs/boot-service.log`, + unitPath: `${root}/unit/t3code.service`, +}); + const writeFakeRuntime = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -130,6 +139,32 @@ const writeFakeRuntime = ( return entryPath; }); +const fakeServiceUnit = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + version: string, + render = renderBootServiceUnit, +) => + Effect.gen(function* () { + const plan = unitPlan(root, version); + yield* fs.makeDirectory(path.dirname(plan.unitPath), { recursive: true }); + const contents = render(plan); + yield* fs.writeFileString(plan.unitPath, contents); + const unit = { + path: plan.unitPath, + reloads: 0, + loadedContents: contents, + failReload: false, + reload: async () => { + unit.reloads += 1; + if (unit.failReload) throw new Error("daemon-reload failed"); + unit.loadedContents = await NodeFSP.readFile(unit.path, "utf8"); + }, + }; + return unit; + }); + it.layer(NodeServices.layer)("service state persistence", (it) => { it.effect( "loads optional service.env literally without changing inherited or managed variables", @@ -217,6 +252,105 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); + it.effect.each([ + { unitVersion: "1.0.0", replacement: "an intentional downgrade" }, + { unitVersion: "1.2.0", replacement: "another T3 home" }, + ])( + "recovers a rolled-back update with unit $unitVersion and preserves $replacement", + ({ unitVersion, replacement }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-repoint-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.1.0"), + "setInterval(() => {}, 1_000);\n", + ); + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.1.0", + update: { + id: "update-1", + fromVersion: "1.1.0", + targetVersion: "1.2.0", + status: "rolled-back", + }, + }), + ); + const unit = yield* fakeServiceUnit(fs, path, root, unitVersion); + const expected = renderBootServiceUnit( + unitPlan(root, unitVersion === "1.0.0" ? "1.1.0" : unitVersion), + ); + const createLauncher = Effect.gen(function* () { + return new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)), { + serviceUnit: unit, + }); + }); + const run = (launcher: Launcher) => + Effect.gen(function* () { + const running = launcher.run(); + yield* Effect.promise(() => launcher.stop("SIGTERM")); + yield* Effect.promise(() => running); + }); + + yield* run(yield* createLauncher); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + + yield* run(yield* createLauncher); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.1.0", + update: { + id: "update-2", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + status: "committed", + }, + }), + ); + const staleLauncher = yield* createLauncher; + // A local install can replace the selection or service owner without stopping this launcher. + const selectedRoot = + replacement === "another T3 home" ? path.join(root, "other-home") : root; + yield* writeFakeRuntime( + fs, + path, + path.join(selectedRoot, "runtime", "versions", "1.0.0"), + "setInterval(() => {}, 1_000);\n", + ); + const selectedStatePath = path.join(selectedRoot, "runtime", "service-state.json"); + yield* Effect.promise(() => + writeServiceState(selectedStatePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + const selectedUnit = renderBootServiceUnit({ + ...unitPlan(selectedRoot, "1.0.0"), + unitPath: unit.path, + }); + yield* fs.writeFileString(unit.path, selectedUnit); + yield* Effect.promise(() => unit.reload()); + const oldHomeState = yield* fs.readFileString(statePath); + const selectedState = yield* fs.readFileString(selectedStatePath); + + yield* run(staleLauncher); + assert.equal(yield* fs.readFileString(statePath), oldHomeState); + assert.equal(yield* fs.readFileString(selectedStatePath), selectedState); + assert.equal(yield* fs.readFileString(unit.path), selectedUnit, replacement); + assert.equal(unit.loadedContents, selectedUnit, replacement); + }), + ); + it.effect("stays idle instead of exiting when another server owns the T3 home", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -257,25 +391,49 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); - it.effect("commits only after the trial reports prepared", () => + const commitUpdate = ( + render = renderBootServiceUnit, + missingUnit = false, + legacyInstall = false, + ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-flow-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service 100% & -" }); const statePath = path.join(root, "runtime", "service-state.json"); const databasePath = path.join(root, "userdata", "state.sqlite"); + const activatedPath = path.join(root, "activated"); yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); yield* fs.writeFileString(databasePath, "before trial"); - const encodedDatabasePath = JSON.stringify(databasePath); + const unit = yield* fakeServiceUnit(fs, path, root, "1.0.0", render); + if (missingUnit) yield* fs.remove(unit.path); const childSource = ` +import assert from "node:assert/strict"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +assert.equal(context.protocol, ${SERVICE_LAUNCHER_PROTOCOL}); if (context.update?.status === "pending") { - process.send({ type: "prepared", updateId: context.update.id }); + const state = JSON.parse(readFileSync(${JSON.stringify(statePath)}, "utf8")); + assert.equal(state.activeVersion, "1.0.0"); + assert.equal(state.update.status, "pending"); + ${ + missingUnit + ? `assert.equal(existsSync(${JSON.stringify(unit.path)}), false);` + : `assert.equal(readFileSync(${JSON.stringify(unit.path)}, "utf8"), ${JSON.stringify(unit.loadedContents)});` + } process.on("message", (message) => { - if (message.type === "committed") process.exit(0); + if (message.type === "committed") { + const state = JSON.parse(readFileSync(${JSON.stringify(statePath)}, "utf8")); + assert.equal(state.protocol, ${SERVICE_LAUNCHER_PROTOCOL}); + assert.equal(state.activeVersion, "1.1.0"); + assert.equal(state.update.status, "committed"); + writeFileSync(${JSON.stringify(activatedPath)}, message.updateId); + process.exit(0); + } }); + process.send({ type: "prepared", updateId: context.update.id }); } else if (context.update === undefined) { - process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} }); + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${JSON.stringify(databasePath)} }); setInterval(() => {}, 1_000); } else { process.exit(0); @@ -289,25 +447,97 @@ if (context.update?.status === "pending") { childSource, ); } - yield* Effect.promise(() => - writeServiceState(statePath, { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "1.0.0", - }), + // The old CLI installs the new runtime but writes state with its own protocol. + yield* fs.writeFileString( + statePath, + `${JSON.stringify( + { + protocol: legacyInstall ? 3 : SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }, + null, + 2, + )}\n`, ); - - const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); - yield* Effect.promise(() => + const launcher = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + { serviceUnit: unit }, + ); + const failure = yield* Effect.promise(() => launcher.run().then( - () => Promise.reject(new Error("launcher unexpectedly completed")), - () => Promise.resolve(), + () => "launcher unexpectedly completed", + (cause: unknown) => String(cause), ), ); - + assert.include(failure, "Active child exited unexpectedly (0)"); const state = yield* Effect.promise(() => readServiceState(statePath)); assert.equal(state.activeVersion, "1.1.0"); assert.equal(state.update?.status, "committed"); - }), + assert.equal(yield* fs.readFileString(activatedPath), state.update?.id); + const recover = () => + Effect.gen(function* () { + const fresh = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + { serviceUnit: unit }, + ); + const running = fresh.run(); + yield* Effect.promise(() => fresh.stop("SIGTERM")); + yield* Effect.promise(() => running); + }); + return { fs, root, unit, recover }; + }); + + it.effect.each([ + { + name: "systemd after a protocol-3 local upgrade", + render: renderBootServiceUnit, + legacyInstall: true, + }, + { + name: "launchd", + legacyInstall: false, + render: (plan: Parameters[0]) => + renderBootServicePlist(plan, { + homeDir: "/Users/test & ", + environmentPath: "/usr/bin:/custom/runtime/versions/1.0.0", + }), + }, + ])( + "commits and repoints $name only after the trial reports prepared", + ({ render, legacyInstall }) => + Effect.gen(function* () { + const { fs, root, unit } = yield* commitUpdate(render, false, legacyInstall); + const expected = render(unitPlan(root, "1.1.0")); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + assert.equal(unit.reloads, 1); + }), + ); + + it.effect( + "keeps a durable commit through unit failure and retries a failed reload on restart", + () => + Effect.gen(function* () { + const { fs, root, unit, recover } = yield* commitUpdate(renderBootServiceUnit, true); + assert.equal(unit.reloads, 0); + const original = renderBootServiceUnit(unitPlan(root, "1.0.0")); + const expected = renderBootServiceUnit(unitPlan(root, "1.1.0")); + assert.equal(unit.loadedContents, original); + yield* fs.writeFileString(unit.path, original); + unit.failReload = true; + yield* recover(); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.reloads, 1); + assert.equal(unit.loadedContents, original); + + unit.failReload = false; + yield* recover(); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + assert.equal(unit.reloads, 2); + }), ); it.effect("rolls back invalid trials with the startup environment", () => @@ -357,9 +587,18 @@ if (context.update?.status === "pending") { activeVersion: "1.0.0", }), ); + const unit = yield* fakeServiceUnit(fs, path, root, "0.9.0"); + const launcher = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + { + serviceUnit: unit, + environment: yield* Effect.promise(() => readServiceEnvironment(root)), + }, + ); yield* Effect.promise(() => - main().then( + launcher.run().then( () => Promise.reject(new Error("launcher unexpectedly completed")), () => Promise.resolve(), ), @@ -368,6 +607,11 @@ if (context.update?.status === "pending") { const state = yield* Effect.promise(() => readServiceState(statePath)); assert.equal(state.activeVersion, "1.0.0"); assert.equal(state.update?.status, "rolled-back"); + assert.equal( + yield* fs.readFileString(unit.path), + renderBootServiceUnit(unitPlan(root, "1.0.0")), + ); + assert.equal(unit.reloads, 1); assert.equal( state.update?.status === "rolled-back" ? state.update.reason : undefined, "invalid-prepared", diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index b46083cccbcd8..e6d3431f39b3b 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -7,6 +7,7 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeUtil from "node:util"; +import { acquireServiceSelectionLock } from "./cloud/serviceSelectionLock.ts"; import type { PendingServiceUpdate, @@ -21,12 +22,14 @@ import { decodeServiceLauncherChildMessage, isExactServiceVersion, parseServiceState, + serviceUnitBaseDirOf, SERVICE_ENV_FILE, SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, SERVICE_RESTART_PENDING_FILE, SERVICE_STOP_MARKER_FILE, + SERVICE_UNIT_ENV, } from "./cloud/serviceProtocol.ts"; const HANDOFF_DELAY_MS = 2_000; @@ -64,6 +67,78 @@ const runtimeSpawnArguments = (paths: ReturnType) => ({ args: ["serve"], }); +/** + * The unit or launch agent that `t3 service install` wrote. Its program names + * the runtime that ran the install, and a remote update never replaces it, so + * after a commit the launcher points it at the committed runtime. Otherwise the + * next start lands on a runtime that nothing in the service state records. + */ +export interface ServiceUnit { + readonly path: string; + /** systemd keeps a loaded copy of the unit; launchd re-reads the plist at bootstrap. */ + readonly reload: () => Promise; +} + +function serviceUnitFromEnvironment(env: NodeJS.ProcessEnv = process.env): ServiceUnit | undefined { + const unitName = env[SERVICE_UNIT_ENV]?.trim(); + const home = env.HOME?.trim(); + if (!unitName || !home) return undefined; + if (unitName.endsWith(".plist")) { + return { + path: NodePath.join(home, "Library", "LaunchAgents", unitName), + reload: () => Promise.resolve(), + }; + } + // The manager's own environment, which decides where it reads user units. + const xdgConfigHome = env.XDG_CONFIG_HOME?.trim(); + const configHome = + xdgConfigHome && NodePath.isAbsolute(xdgConfigHome) + ? xdgConfigHome + : NodePath.join(home, ".config"); + return { + path: NodePath.join(configHome, "systemd", "user", unitName), + reload: () => + new Promise((resolve, reject) => { + NodeChildProcess.spawn("systemctl", ["--user", "daemon-reload"], { + stdio: "ignore", + timeout: 30_000, + }) + .once("error", reject) + .once("exit", (code, signal) => + code === 0 + ? resolve() + : reject(new Error(`systemctl --user daemon-reload exited with ${code ?? signal}`)), + ); + }), + }; +} + +// systemd's ExecStart value, or the plist string before the __service-launcher argument. +// Only the version segment changes, so quoted or escaped paths stay as rendered. +// The launch agent's PATH names a versions directory without the executable; leave it. +const SERVICE_UNIT_PROGRAMS = [ + /^(ExecStart=.*?[\\/]runtime[\\/]versions[\\/])([^\\/\s"]+)([\\/]t3)(?=["\s])/m, + /([^<]*?[\\/]runtime[\\/]versions[\\/])([^\\/<]+)([\\/]t3<\/string>\s*__service-launcher<\/string>)/, +] as const; + +/** + * The unit contents with their program pointed at `version`'s runtime. A unit + * already at that version or a newer one is returned unchanged: `t3 update` + * may have repointed it to a later version while this commit was in flight. + */ +function repointServiceUnit(contents: string, version: string): string { + for (const program of SERVICE_UNIT_PROGRAMS) { + const match = program.exec(contents); + if (match === null) continue; + const [, prefix = "", current = "", suffix = ""] = match; + if (isExactServiceVersion(current) && compareExactServiceVersions(current, version) >= 0) { + return contents; + } + return contents.replace(program, () => `${prefix}${version}${suffix}`); + } + return contents; +} + const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; const RESTORE_MARKER = ".restore-pending"; @@ -445,8 +520,12 @@ export async function readServiceState(filePath: string): Promise } export async function writeServiceState(filePath: string, state: ServiceState): Promise { + await NodeFSP.mkdir(NodePath.dirname(filePath), { recursive: true, mode: 0o700 }); + await replaceFileDurably(filePath, `${JSON.stringify(state, null, 2)}\n`); +} + +async function replaceFileDurably(filePath: string, contents: string): Promise { const directory = NodePath.dirname(filePath); - await NodeFSP.mkdir(directory, { recursive: true, mode: 0o700 }); const tempPath = NodePath.join( directory, `.${NodePath.basename(filePath)}.${process.pid}.${NodeCrypto.randomUUID()}`, @@ -454,7 +533,7 @@ export async function writeServiceState(filePath: string, state: ServiceState): let handle: NodeFSP.FileHandle | undefined; try { handle = await NodeFSP.open(tempPath, "wx", 0o600); - await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8"); + await handle.writeFile(contents, "utf8"); await handle.sync(); await handle.close(); handle = undefined; @@ -542,6 +621,7 @@ export class Launcher { #done = false; readonly #completion = Promise.withResolvers(); readonly #backupSpaceProbe: Partial | undefined; + readonly #serviceUnit: ServiceUnit | undefined; readonly #environment: Readonly; constructor( @@ -549,11 +629,13 @@ export class Launcher { state: ServiceState, options: { readonly backupSpaceProbe?: Partial; + readonly serviceUnit?: ServiceUnit; readonly environment?: Readonly; } = {}, ) { this.#baseDir = baseDir; this.#backupSpaceProbe = options.backupSpaceProbe; + this.#serviceUnit = options.serviceUnit; this.#environment = { ...(options.environment ?? process.env) }; this.#statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); this.#state = state; @@ -620,20 +702,66 @@ export class Launcher { this.#timer = undefined; } + async #selectState(next: ServiceState): Promise { + const release = await acquireServiceSelectionLock(this.#statePath); + try { + const selected = await readServiceState(this.#statePath); + // Local installs replace the selection and clear its update record. An old + // launcher must not restore its own selection when asked to update again. + if ( + selected.activeVersion !== this.#state.activeVersion || + selected.update?.id !== this.#state.update?.id || + selected.update?.status !== this.#state.update?.status + ) { + throw new Error("Service selection changed. Restart the service before updating."); + } + await writeServiceState(this.#statePath, next); + this.#state = next; + } finally { + release(); + } + } + async #recover(): Promise { // Clear old stop markers to preserve future tunnels; clear restart-pending only for the awaited version to avoid CLI races. await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); const restartPending = restartPendingPath(this.#baseDir); - const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch(() => undefined); - if (awaitedVersion?.trim() === this.#state.activeVersion) { - await NodeFSP.rm(restartPending, { force: true }).catch(() => undefined); + try { + const release = await acquireServiceSelectionLock(this.#statePath); + try { + const selected = await readServiceState(this.#statePath); + const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch( + () => undefined, + ); + const awaited = awaitedVersion?.trim(); + if ( + selected.activeVersion === this.#state.activeVersion && + selected.update?.id === this.#state.update?.id && + selected.update?.status === this.#state.update?.status && + awaited !== undefined && + isExactServiceVersion(awaited) && + compareExactServiceVersions(awaited, this.#state.activeVersion) <= 0 + ) { + await NodeFSP.rm(restartPending, { force: true }); + } + } finally { + release(); + } + } catch (error) { + process.stderr.write( + `[service-launcher] Could not clear ${restartPending}: ${error instanceof Error ? error.message : String(error)}\n`, + ); } const update = this.#state.update; if (update?.status !== "pending") { if (update !== undefined) { await discardDatabaseBackup(this.#baseDir, update.id).catch(() => undefined); } - await this.#startChild(this.#state.activeVersion, "active", update); + try { + await this.#startChild(this.#state.activeVersion, "active", update); + } finally { + if (update !== undefined) await this.#repointServiceUnit(this.#state.activeVersion); + } return; } if (await databaseRestorePending(this.#baseDir, update)) { @@ -780,8 +908,12 @@ export class Launcher { status: "pending", }; const next: ServiceState = { ...this.#state, update: pending }; - await writeServiceState(this.#statePath, next); - this.#state = next; + try { + await this.#selectState(next); + } catch (error) { + await reject(error instanceof Error ? error.message : String(error)); + return; + } await sendMessage(child.process, { type: "update-accepted", updateId: pending.id }); this.#timer = setTimeout(() => this.#enqueue(() => this.#beginTrial(child)), HANDOFF_DELAY_MS); } @@ -827,11 +959,53 @@ export class Launcher { activeVersion: pending.targetVersion, update: committed, }; - await writeServiceState(this.#statePath, next); - this.#state = next; + await this.#selectState(next); child.role = "active"; await discardDatabaseBackup(this.#baseDir, committed.id).catch(() => undefined); - await sendMessage(child.process, { type: "committed", updateId: committed.id }); + try { + await sendMessage(child.process, { type: "committed", updateId: committed.id }); + } finally { + await this.#repointServiceUnit(committed.targetVersion); + } + } + + // Selection is durable before this best-effort unit reconciliation. + async #repointServiceUnit(version: string): Promise { + const unit = this.#serviceUnit; + if (unit === undefined) return; + try { + const release = await acquireServiceSelectionLock(unit.path); + try { + // The installer writes state before the unit under this lease. Do not + // acquire its state lease here: the installer takes that lease first. + const selected = await readServiceState(this.#statePath); + if ( + selected.activeVersion !== this.#state.activeVersion || + selected.update?.id !== this.#state.update?.id || + selected.update?.status !== this.#state.update?.status + ) { + return; + } + const contents = await NodeFSP.readFile(unit.path, "utf8"); + const installedBaseDir = serviceUnitBaseDirOf(contents); + if ( + installedBaseDir === undefined || + NodePath.resolve(installedBaseDir) !== NodePath.resolve(this.#baseDir) + ) { + return; + } + const repointed = repointServiceUnit(contents, version); + if (repointed !== contents) await replaceFileDurably(unit.path, repointed); + // A previous rename may have succeeded before reload failed. + await unit.reload(); + } finally { + release(); + } + } catch (error) { + process.stderr.write( + `[service-launcher] Could not point ${unit.path} at t3@${version}: ${error instanceof Error ? error.message : String(error)}\n`, + ); + } } async #handlePreparedTimeout(child: ManagedChild): Promise { @@ -887,9 +1061,9 @@ export class Launcher { activeVersion: pending.fromVersion, update: terminalUpdate({ pending, status: "failed", reason: "state-dir-owned" }), }; - await writeServiceState(this.#statePath, next); - this.#state = next; + await this.#selectState(next); await discardDatabaseBackup(this.#baseDir, pending.id).catch(() => undefined); + await this.#repointServiceUnit(next.activeVersion); } // Signal listeners alone do not keep Node alive. this.#timer = setInterval(() => {}, 2_147_483_647); @@ -915,10 +1089,13 @@ export class Launcher { activeVersion: pending.fromVersion, update: outcome, }; - await writeServiceState(this.#statePath, next); - this.#state = next; + await this.#selectState(next); await discardDatabaseBackup(this.#baseDir, pending.id).catch(() => undefined); - await this.#startChild(next.activeVersion, "active", outcome); + try { + await this.#startChild(next.activeVersion, "active", outcome); + } finally { + await this.#repointServiceUnit(next.activeVersion); + } } } @@ -930,5 +1107,10 @@ export async function main(): Promise { const environment = await readServiceEnvironment(baseDir); const statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); const state = await readServiceState(statePath); - await new Launcher(baseDir, state, { environment }).run(); + const serviceUnit = serviceUnitFromEnvironment(); + await new Launcher( + baseDir, + state, + serviceUnit === undefined ? { environment } : { environment, serviceUnit }, + ).run(); } diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 1536884578d4b..e0f1d286a1608 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -4,14 +4,30 @@ A [stable launcher](../../apps/server/src/serviceLauncher.ts) owns the runtime selected by systemd or launchd. It is the only runtime writer of durable service state. Server children request updates over inherited IPC; they never rewrite their service definition or select their own replacement. Local service commands -may replace the launcher and state while the service is stopped. Foreground CLI -processes do not self-update. +may replace the launcher and state, optionally deferring the restart. Foreground +CLI processes do not self-update. + +Both writers serialize state and unit writes through +[file leases](../../apps/server/src/cloud/serviceSelectionLock.ts). The installer +takes the state lease before the unit lease; the launcher never holds both, so a +unit failure cannot block a durable commit. Manager stop/start commands run outside +both leases, since stopping a launcher that waits on a lease would deadlock. A +running launcher refuses further updates once a local command changes its selection. Exact-version installs keep restarts independent of npm cache eviction or a moving release tag. Installation and preflight happen in staging before publishing an immutable runtime. Preflight checks the launcher protocol because a target that -needs new rollback guarantees cannot safely run under an older launcher. Upgrading -that launcher requires a local service update. +needs new rollback guarantees cannot safely run under an older launcher. A remote +update replaces only the child, so upgrading the launcher requires a local service +update with a restart. + +After a commit or rollback the launcher points the unit at the selected runtime and +reloads the manager, so the next service start runs that version's launcher; the +protocol check is what makes that safe. Each start after a finished update retries this, because +a previous reload may have failed, and leaves a unit naming a newer runtime or another +T3 home alone. Until a deferred local upgrade restarts, systemd (before daemon-reload) +and launchd (before bootstrap) can respawn the old launcher, which the new runtime +refuses to run under. ## Commit boundary diff --git a/docs/user/updating.md b/docs/user/updating.md index 7e62c7bf580fc..123306077f8f5 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -118,6 +118,11 @@ asks before restarting the background service; if you decline, run stop it and start it again afterwards with your usual options such as `--host` or `--tailscale-serve`. +A background service installed by an older release refuses **Update server** +until you update it once on the host and let that update restart it. If you +decline the restart, the service can stay down after a crash or reboot until you +run `t3 service restart`. + If you run the server with `npx` rather than an installed `t3`, there is nothing to update on the host: stop the server and relaunch it as `npx t3@` with the same subcommand and options. From 8d236998d074799a6e88a495ba3112e87d6ab696 Mon Sep 17 00:00:00 2001 From: Ibrahim Elkamali Date: Wed, 11 Nov 2026 12:53:39 +0300 Subject: [PATCH 09/56] fix(server): a limited provider's queue stays held after another provider's turn succeeds (#22425) Co-authored-by: Mantas Kantautas Co-authored-by: maria-rcks Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 43 ++++-- .../src/orchestration-v2/runtimeLayer.test.ts | 134 ++++++++++++++++++ 2 files changed, 165 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 636a843e2bc4c..840f0731691b0 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -899,9 +899,15 @@ function queueTailModelSelection( ); } +/** + * The provider's latest run stopped on its usage limit and nothing ran after it. + * With `now` (automatic starts), a run on another provider since does not lift + * a reported reset still in the future; an unknown reset is lifted, as transient 429s are. + */ function usageLimitHoldsQueue( projection: Pick, providerInstanceId: ModelSelection["instanceId"], + now?: DateTime.Utc, ): boolean { let latestSession: OrchestrationV2ThreadProjection["providerSessions"][number] | undefined; for (const session of projection.providerSessions) { @@ -913,10 +919,16 @@ function usageLimitHoldsQueue( latestSession = session; } } - return ( - usageLimitBlockedRun(projection.runs, projection.turnItems, latestSession?.lastError ?? null) - ?.providerInstanceId === providerInstanceId + const limited = usageLimitBlockedRun( + projection.runs.filter((run) => run.providerInstanceId === providerInstanceId), + projection.turnItems, + latestSession?.lastError ?? null, ); + if (limited === null) return false; + if (latestExecutedRun(projection.runs)?.id === limited.id) return true; + if (now === undefined) return false; + const resetAt = latestRootProviderFailure(limited, projection.turnItems)?.resetAt; + return resetAt != null && Date.parse(resetAt) > DateTime.toEpochMillis(now); } function latestStableRun( @@ -1920,6 +1932,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly failedRunId?: RunId; readonly endedRunId?: RunId; readonly queuedRunId?: RunId; + readonly userStarted?: boolean; }, ) { if (!(yield* projectionStore.canStartQueuedRun(threadId))) return; @@ -1951,7 +1964,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerInstanceId: modelSelection.instanceId, }; // A limited provider would fail and consume every waiting message. - if (usageLimitHoldsQueue(projection, queuedRun.providerInstanceId)) { + if ( + usageLimitHoldsQueue( + projection, + queuedRun.providerInstanceId, + options?.userStarted === true ? undefined : yield* DateTime.now, + ) + ) { return; } // Waits without a hold: lowering the thread's mode starts the queue again. @@ -14009,10 +14028,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (command.type === "queue.resume" || command.type === "queued-run.start") { yield* mapDispatchError(command)( - startNextQueuedRun( - command.threadId, - command.type === "queued-run.start" ? { queuedRunId: command.runId } : undefined, - ), + startNextQueuedRun(command.threadId, { + userStarted: true, + ...(command.type === "queued-run.start" ? { queuedRunId: command.runId } : {}), + }), ); } return { @@ -14229,10 +14248,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (command.type === "thread.runtime-mode.set" && runtimeModeCeiling !== undefined) ) { yield* mapDispatchError(command)( - startNextQueuedRun( - command.threadId, - command.type === "queued-run.start" ? { queuedRunId: command.runId } : undefined, - ), + startNextQueuedRun(command.threadId, { + userStarted: command.type !== "thread.runtime-mode.set", + ...(command.type === "queued-run.start" ? { queuedRunId: command.runId } : {}), + }), ); } if ( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 245c012d56f84..a31a96cf5b463 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6284,6 +6284,140 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each(["the reset passes", "the user resumes"] as const)( + "holds a limited provider's queue through another provider's turn until %s", + (release) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`runtime-layer-queue-limit-switch-${release}`); + const switchedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + createdBy: "user", + creationSource: "web", + projectId: ProjectId.make(`${threadId}:project`), + title: "Limit switch", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const send = ( + name: string, + selection: typeof modelSelection | typeof switchedSelection, + type: "start_immediately" | "queue_after_active", + ) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${name}`), + threadId, + messageId: MessageId.make(`${threadId}:${name}`), + text: name, + attachments: [], + createdBy: "user", + creationSource: "web", + modelSelection: selection, + dispatchMode: { type }, + }); + yield* send("limited", modelSelection, "start_immediately"); + yield* send("queued", modelSelection, "queue_after_active"); + const [limited, queued] = (yield* orchestrator.getThreadProjection(threadId)).runs; + assert.isDefined(limited); + assert.isDefined(queued); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:error`), + type: "turn-item.updated", + threadId, + runId: limited.id, + nodeId: limited.rootNodeId!, + providerInstanceId: limited.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:error`), + type: "error", + threadId, + runId: limited.id, + nodeId: limited.rootNodeId!, + providerThreadId: limited.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Usage limit", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "usage_limit", + message: "Usage limit reached", + code: "usage_limit", + retryable: null, + resetAt: DateTime.formatIso(DateTime.add(now, { hours: 1 })), + }, + }, + }, + { + id: EventId.make(`${threadId}:failed`), + type: "run.updated", + threadId, + runId: limited.id, + providerInstanceId: limited.providerInstanceId, + occurredAt: now, + payload: { ...limited, status: "failed", completedAt: now }, + }, + ], + }); + + // A message on another provider starts ahead of the held queue and + // completes there. + yield* send("switch", switchedSelection, "start_immediately"); + const switched = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === MessageId.make(`${threadId}:switch`), + ); + assert.equal(switched?.status, "starting"); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:switch:completed`), + type: "run.updated", + threadId, + runId: switched!.id, + providerInstanceId: switched!.providerInstanceId, + occurredAt: now, + payload: { ...switched!, status: "completed", completedAt: now }, + }, + ], + }); + + yield* orchestrator.resumeQueuedRuns; + const held = yield* orchestrator.getThreadProjection(threadId); + assert.equal(held.runs.find((run) => run.id === queued.id)?.status, "queued"); + + if (release === "the reset passes") { + yield* TestClock.adjust("61 minutes"); + yield* orchestrator.resumeQueuedRuns; + } else { + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + } + const reset = yield* orchestrator.getThreadProjection(threadId); + const resumed = reset.runs.find((run) => run.id === queued.id); + assert.equal(resumed?.status, "starting"); + assert.deepEqual(resumed?.modelSelection, modelSelection); + }), + ); + it.effect.each([ ["server", modelSelection], ["provider", modelSelection], From 9ef52de2930782e413ca9d2b5fef7500496d7123 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:58:59 -0800 Subject: [PATCH 10/56] fix(shared): keep spreadsheet shorthand as inline code (#23203) Co-authored-by: Claude Opus 5.5 --- packages/shared/src/markdownLinks.test.ts | 7 +++++++ packages/shared/src/markdownLinks.ts | 13 ++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index aba5a74c01d3b..788424168bbe7 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -40,6 +40,13 @@ describe("inlineCodeFilePathCandidate", () => { ["/tmp/.../scratchpad/notes.md", null], ["~/projects/…/notes.md", null], ["src/.../main.ts", null], + // regression: #22181 a `:line` suffix does not make an extensionless basename a file + ["I/W217:220", null], + ["bin/run:12", null], + ["docs/README:12", "docs/README:12"], + ["./bin/run:12", "./bin/run:12"], + ["src/資料.備份:12", "src/資料.備份:12"], + ["src/foo.c++:12", "src/foo.c++:12"], ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => { expect(inlineCodeFilePathCandidate(source)).toBe(candidate); }); diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index edcc2cc5060d5..b0408486c8f04 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -37,6 +37,7 @@ const PATH_SEPARATOR_PATTERN = /[\\/]/; const ELIDED_PATH_SEGMENT_PATTERN = /(?:^|[\\/])(?:\.{3,}|…)(?:[\\/]|$)/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; +const ANY_FILE_EXTENSION_PATTERN = /\.[^./\\]+$/; const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; @@ -200,7 +201,17 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { .split(/[\\/]/) .at(-1) ?? ""; if (VERSION_SUFFIX_PATTERN.test(basename)) return null; - if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null; + if (!FILE_EXTENSION_PATTERN.test(basename)) { + if (!hasPosition) return null; + // A `:line` suffix alone does not make `I/W217:220` a file reference. + if ( + withoutPosition.includes("/") && + !ANY_FILE_EXTENSION_PATTERN.test(basename) && + !EXTENSIONLESS_FILE_NAMES.has(basename) + ) { + return null; + } + } } return candidate; } From 25f4c9d44e98ca3a4b0e51cfecc1db122cc4d87b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 02:04:12 -0800 Subject: [PATCH 11/56] fix(web): offer retry while an environment reconnects (#23194) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/chat/useSystemComposerBanners.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/useSystemComposerBanners.tsx b/apps/web/src/components/chat/useSystemComposerBanners.tsx index 1d29dfcb19ea1..f172f9e4b7fbc 100644 --- a/apps/web/src/components/chat/useSystemComposerBanners.tsx +++ b/apps/web/src/components/chat/useSystemComposerBanners.tsx @@ -195,7 +195,7 @@ export function useSystemComposerBanners(input: SystemComposerBannersInput) { ) : undefined, actions: ( <> - {!environmentReconnecting ? ( + {unavailableConnection.phase !== "connecting" ? ( ) : null} {disconnectAction} From c9dfc404ccde9a4c8be901b287026dd19e4ed19a Mon Sep 17 00:00:00 2001 From: fixfon <12512757+fixfon@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:09:25 +0300 Subject: [PATCH 12/56] fix(server): custom Codex providers no longer report native quota (#18622) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Julius Marminge --- .../server/src/provider/CodexProvider.test.ts | 31 ++++- apps/server/src/provider/CodexProvider.ts | 116 +++++++++++++----- 2 files changed, 118 insertions(+), 29 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 30f63b21f318e..c9a992bcf5160 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,6 +1,10 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + codexRoutesSpendNativeQuota, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -153,3 +157,28 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it("reports native quota only for an OpenAI login on a native route", () => { + const chatgpt = { type: "chatgpt", email: "a@example.com", planType: "pro" } as const; + const hub = { + model_provider: "hub", + model_providers: { hub: { base_url: "https://hub.example.com/v1" } }, + }; + const cases = [ + ["default route", chatgpt, true, {}, true], + ["openai base url", chatgpt, true, { openai_base_url: "https://chatgpt.com/x" }, true], + ["openai proxy url", chatgpt, true, { openai_base_url: "https://proxy.example.com" }, false], + ["custom provider", chatgpt, true, hub, false], + ["unknown provider id", chatgpt, true, { model_provider: "missing" }, false], + ["unreadable config", chatgpt, true, null, false], + ["login not required", chatgpt, false, {}, false], + ["api key", { type: "apiKey" }, true, {}, false], + ] as const; + for (const [name, account, requiresOpenaiAuth, config, expected] of cases) { + assert.strictEqual( + codexRoutesSpendNativeQuota({ account, requiresOpenaiAuth }, config), + expected, + name, + ); + } +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2e56eede84cd6..0068e899e011e 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -70,6 +70,44 @@ type CodexRateLimitsProbe = } | { readonly failure: string }; +const CodexRoutingConfig = Schema.Struct({ + model_provider: Schema.optionalKey(Schema.NullOr(Schema.String)), + openai_base_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + model_providers: Schema.optionalKey( + Schema.Record( + Schema.String, + Schema.Struct({ base_url: Schema.optionalKey(Schema.NullOr(Schema.String)) }), + ), + ), +}); +const decodeCodexRoutingConfig = Schema.decodeUnknownOption(CodexRoutingConfig); + +function routesToNativeOpenai(config: unknown): boolean { + const decoded = decodeCodexRoutingConfig(config); + if (Option.isNone(decoded)) return false; + const routing = decoded.value; + const providerId = routing.model_provider ?? "openai"; + const provider = routing.model_providers?.[providerId]; + if (providerId !== "openai" && !provider) return false; + const baseUrl = + providerId === "openai" ? routing.openai_base_url || undefined : provider?.base_url; + // A provider without an override uses Codex's auth-dependent OpenAI default. + if (baseUrl == null) return true; + if (!URL.canParse(baseUrl)) return false; + const url = new URL(baseUrl); + return ( + url.protocol === "https:" && + (url.hostname === "api.openai.com" || url.hostname === "chatgpt.com") + ); +} + +// A stored login does not mean this provider's traffic spends that login's quota. +export const codexRoutesSpendNativeQuota = ( + account: CodexSchema.V2GetAccountResponse, + config: unknown, +): boolean => + account.requiresOpenaiAuth && account.account?.type !== "apiKey" && routesToNativeOpenai(config); + const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; const CODEX_CATALOG_REFRESH_TIMEOUT_MS = 3_000; const CODEX_CONFIG_PROBE_TIMEOUT_MS = 3_000; @@ -90,6 +128,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly nativeUsage?: boolean; readonly rateLimits?: CodexRateLimitsProbe; readonly version: string | undefined; readonly models: ReadonlyArray | undefined; @@ -526,6 +565,34 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu return { client, initialize }; }); +// Usage is an enrichment: a failure or a slow answer degrades to "no usage this +// probe" rather than costing the account and models. +const readCodexRateLimits = ( + client: CodexClient.CodexAppServerClient["Service"], + deadlineMs: number, +) => + withCodexDiscoveryDeadline( + client.request("account/rateLimits/read", null), + deadlineMs, + RATE_LIMITS_PROBE_TIMEOUT_MS, + ).pipe( + Effect.map((response): CodexRateLimitsProbe => + response + ? { + snapshot: response.rateLimits, + rateLimitsByLimitId: response.rateLimitsByLimitId, + resetCredits: response.rateLimitResetCredits, + accountId: response.accountId, + } + : { failure: "Codex did not answer the usage request." }, + ), + Effect.catch((error) => + Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( + Effect.as({ failure: codexRateLimitsFailureMessage(error) }), + ), + ), + ); + const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { readonly binaryPath: string; readonly homePath?: string; @@ -552,7 +619,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // Optional discovery must finish before the outer timeout can discard the account result. const deadlineMs = spawnedAtMs + CODEX_AUTH_PROBE_TIMEOUT_MS - CODEX_DISCOVERY_MARGIN_MS; - const [skillsResponse, models, serviceTier, rateLimits] = yield* Effect.all( + const [skillsResponse, models, { serviceTier, nativeUsage, rateLimits }] = yield* Effect.all( [ withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs), accountResponse.account?.type === "chatgpt" @@ -569,40 +636,32 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun deadlineMs, CODEX_CONFIG_PROBE_TIMEOUT_MS, ).pipe( - Effect.map((response) => response?.config.service_tier ?? undefined), Effect.catch(() => Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined))), + Effect.flatMap((config) => { + // Only a confirmed native route may probe or publish the login's quota. + const nativeUsage = + !input.skipNativeUsage && + config !== undefined && + codexRoutesSpendNativeQuota(accountResponse, config.config); + const readUsage: Effect.Effect = nativeUsage + ? readCodexRateLimits(client, deadlineMs) + : Effect.undefined; + return readUsage.pipe( + Effect.map((rateLimits) => ({ + serviceTier: config?.config.service_tier ?? undefined, + nativeUsage, + rateLimits, + })), + ); + }), ), - // Usage is an enrichment: a failure or a slow answer degrades to "no - // usage this probe" rather than costing the account and models. - input.skipNativeUsage - ? Effect.undefined - : withCodexDiscoveryDeadline( - client.request("account/rateLimits/read", null), - deadlineMs, - RATE_LIMITS_PROBE_TIMEOUT_MS, - ).pipe( - Effect.map((response): CodexRateLimitsProbe => - response - ? { - snapshot: response.rateLimits, - rateLimitsByLimitId: response.rateLimitsByLimitId, - resetCredits: response.rateLimitResetCredits, - accountId: response.accountId, - } - : { failure: "Codex did not answer the usage request." }, - ), - Effect.catch((error) => - Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( - Effect.as({ failure: codexRateLimitsFailureMessage(error) }), - ), - ), - ), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + nativeUsage, ...(rateLimits ? { rateLimits } : {}), version, models, @@ -845,8 +904,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } : accountProbeStatus(snapshot); + // Sign-in guidance still applies when Codex itself says it needs an OpenAI login. const usageLimits = - snapshot.account.account?.type === "apiKey" + !snapshot.nativeUsage && !accountStatus.message ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits ? makeUnavailableUsageLimits({ From e93f6ea36178f18b4cd38a9ec4939151c086f440 Mon Sep 17 00:00:00 2001 From: Arman Arutiunov Date: Wed, 11 Nov 2026 10:14:38 +0000 Subject: [PATCH 13/56] fix(desktop): Cmd+F now searches the page in the integrated browser (#22960) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/preview.ts | 26 ++++ apps/desktop/src/preload.ts | 19 +++ apps/desktop/src/preview/GuestProtocol.ts | 1 + apps/desktop/src/preview/Manager.test.ts | 130 ++++++++++++++++ apps/desktop/src/preview/Manager.ts | 134 ++++++++++++++--- apps/desktop/src/preview/PickPreload.ts | 21 +++ apps/web/src/components/CommandPalette.tsx | 30 +++- .../components/ReopenClosedViewShortcut.tsx | 1 + .../src/components/preview/PreviewFindBar.tsx | 139 ++++++++++++++++++ .../components/preview/PreviewMoreMenu.tsx | 10 ++ .../preview/PreviewView.browser.test.tsx | 20 +++ .../src/components/preview/PreviewView.tsx | 56 ++++++- .../components/preview/previewActionBus.ts | 3 +- .../settings/KeybindingsSettings.logic.ts | 1 + apps/web/src/shortcuts/chatRouteCommands.ts | 1 + docs/user/keybindings.md | 7 + packages/contracts/src/ipc.ts | 20 +++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 20 files changed, 599 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/preview/PreviewFindBar.tsx diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index aebbc1c24f8c2..09f79cfc82f76 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -94,6 +94,8 @@ export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor" export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_FIND_IN_PAGE_CHANNEL = "desktop:preview-find-in-page"; +export const PREVIEW_STOP_FIND_IN_PAGE_CHANNEL = "desktop:preview-stop-find-in-page"; export const PREVIEW_SET_SHOWN_CHANNEL = "desktop:preview-set-shown"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; @@ -118,6 +120,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; +export const PREVIEW_FIND_RESULT_CHANNEL = "desktop:preview-find-result"; export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 98a0aab831645..ea291b66b3149 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -5,6 +5,7 @@ import { DesktopPreviewPickElementInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewConfigInputSchema, + DesktopPreviewFindInPageInputSchema, DesktopPreviewNavigateInputSchema, DesktopPreviewRecordingArtifactSchema, DesktopPreviewRecordingSaveInputSchema, @@ -59,6 +60,9 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeOpenLinks((event) => electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event), ); + yield* manager.subscribeFindResults((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, event), + ); }); const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ @@ -116,6 +120,21 @@ const navigate = DesktopIpc.makeIpcMethod({ }), }); +const findInPage = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_FIND_IN_PAGE_CHANNEL, + payload: DesktopPreviewFindInPageInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.findInPage")(function* ({ + tabId, + text, + forward, + newSession, + }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.findInPage(tabId, text, { forward, newSession }); + }), +}); + const tabMethod = ( channel: string, name: string, @@ -169,6 +188,11 @@ const hardReload = tabMethod( "desktop.ipc.preview.hardReload", (manager, tabId) => manager.hardReload(tabId), ); +const stopFindInPage = tabMethod( + IpcChannels.PREVIEW_STOP_FIND_IN_PAGE_CHANNEL, + "desktop.ipc.preview.stopFindInPage", + (manager, tabId) => manager.stopFindInPage(tabId), +); const setZoomFactor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL, payload: DesktopPreviewSetZoomFactorInputSchema, @@ -452,6 +476,8 @@ export const methods = [ zoomOut, resetZoom, hardReload, + findInPage, + stopFindInPage, setColorScheme, setZoomFactor, setAudioMuted, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2dbc4eeda3ae5..84f3af63631f4 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,6 +1,7 @@ import type { DesktopBridge, DesktopPreviewAnnotationVoiceEvent, + DesktopPreviewFindResultEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, @@ -369,6 +370,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + findInPage: (tabId, text, { forward, newSession }) => + ipcRenderer.invoke(IpcChannels.PREVIEW_FIND_IN_PAGE_CHANNEL, { + tabId, + text, + forward, + newSession, + }), + stopFindInPage: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_STOP_FIND_IN_PAGE_CHANNEL, { tabId }), setShown: (tabId, shown) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, { tabId, shown }), openDevTools: (tabId) => @@ -469,5 +479,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); }, + onFindResult: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, findEvent: unknown) => { + if (typeof findEvent !== "object" || findEvent === null) return; + listener(findEvent as DesktopPreviewFindResultEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, wrappedListener); + }, }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index a4fd88c475bca..fbf90a38c4eb9 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -6,6 +6,7 @@ export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; +export const PAGE_UNHANDLED_SHORTCUT_CHANNEL = "preview:page-unhandled-shortcut"; export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor"; export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; export const RECORDING_KEY_CHANNEL = "preview:recording-key"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index b4e4b2a8b75ce..79a059d828943 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2,6 +2,7 @@ import { it as effectIt } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { + DesktopPreviewFindResultEvent, DesktopPreviewRecordingInputEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -812,6 +813,135 @@ describe("PreviewManager", () => { ), ); + effectIt.effect.each([ + ["darwin", true], + ["linux", false], + ["win32", false], + ] as const)( + "lets the page and its iframes claim the find shortcut before the host opens find on %s", + ([platform, usesMeta]) => + withManager( + (manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const send = vi.fn(); + const mainFrame = { processId: 1, routingId: 1 }; + Object.assign(preview.webContents, { + hostWebContents: { isDestroyed: () => false, send }, + mainFrame, + focusedFrame: mainFrame, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_page_find"); + yield* manager.registerWebview("tab_page_find", 42); + yield* manager.setForwardedShortcuts([ + { command: "preview.find", shortcut: parseKeybindingShortcut("mod+f")! }, + ]); + const beforeInput = preview.listeners.get("before-input-event")!; + const findKey = { + type: "keyDown", + key: "f", + code: "KeyF", + meta: usesMeta, + control: !usesMeta, + }; + const preventDefault = vi.fn(); + + beforeInput( + { preventDefault } as never, + { ...findKey, shift: false, alt: false } as never, + ); + expect(preventDefault).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + + const ipcOn = (preview.webContents as Electron.WebContents).ipc.on as ReturnType< + typeof vi.fn + >; + const pageUnhandledShortcut = ipcOn.mock.calls.find( + ([channel]) => channel === "preview:page-unhandled-shortcut", + )![1]; + pageUnhandledShortcut( + {}, + { + key: "f", + code: "KeyF", + metaKey: usesMeta, + ctrlKey: !usesMeta, + shiftKey: false, + altKey: false, + }, + ); + expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", "preview.find"); + + Object.assign(preview.webContents, { focusedFrame: { processId: 1, routingId: 2 } }); + beforeInput( + { preventDefault } as never, + { ...findKey, shift: false, alt: false } as never, + ); + expect(preventDefault).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledOnce(); + + Object.assign(preview.webContents, { focusedFrame: mainFrame }); + yield* manager.setForwardedShortcuts([ + { command: "preview.find", shortcut: parseKeybindingShortcut("ctrl+alt+8")! }, + ]); + pageUnhandledShortcut( + {}, + { + key: "[", + code: "Digit8", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: true, + }, + ); + expect(send).toHaveBeenCalledTimes(usesMeta ? 2 : 1); + }), + platform, + ), + ); + + effectIt.effect("searches the guest page and reports its match counts", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const findInPage = vi.fn(); + const stopFindInPage = vi.fn(); + Object.assign(preview.webContents, { findInPage, stopFindInPage }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_find"); + yield* manager.registerWebview("tab_find", 42); + const reported = yield* Deferred.make(); + yield* manager.subscribeFindResults((event) => Deferred.succeed(reported, event)); + + yield* manager.findInPage("tab_find", "needle", { forward: true, newSession: true }); + expect(findInPage).toHaveBeenCalledExactlyOnceWith("needle", { + forward: true, + findNext: true, + }); + + preview.listeners.get("found-in-page")!( + {} as never, + { + requestId: 1, + activeMatchOrdinal: 2, + matches: 5, + finalUpdate: true, + } as never, + ); + expect(yield* Deferred.await(reported)).toEqual({ + tabId: "tab_find", + activeMatchOrdinal: 2, + matches: 5, + }); + + yield* manager.stopFindInPage("tab_find"); + expect(stopFindInPage).toHaveBeenCalledExactlyOnceWith("clearSelection"); + }), + ), + ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { const loggedErrors: Array = []; const logger = Logger.make(({ message }) => { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index c668c39061e00..14bdd0e1d6ba9 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -12,6 +12,7 @@ import type { DesktopPreviewAnnotationVoiceState, DesktopPreviewColorScheme, DesktopPreviewFavicon, + DesktopPreviewFindResultEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, PreviewAnnotationPayload, @@ -80,6 +81,7 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, @@ -437,6 +439,23 @@ type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; +type FindResultListener = (event: DesktopPreviewFindResultEvent) => Effect.Effect; + +const PAGE_FIRST_FORWARDED_COMMANDS: ReadonlySet = new Set([ + "preview.find", +]); + +const decodePageShortcutKeys = Schema.decodeUnknownOption( + Schema.Struct({ + key: Schema.String, + code: Schema.String, + metaKey: Schema.Boolean, + ctrlKey: Schema.Boolean, + shiftKey: Schema.Boolean, + altKey: Schema.Boolean, + }), +); + // about:blank inherits relaxed guest preferences; external editor schemes must reach the OS handler. const POPUP_PROTOCOLS = new Set(["http:", "https:"]); @@ -557,6 +576,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); const openLinkListenersRef = yield* Ref.make>(new Set()); + const findResultListenersRef = yield* Ref.make>(new Set()); const recordingInputListenersRef = yield* Ref.make>( new Set(), ); @@ -822,7 +842,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link", + eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link" | "find-result", tabId: string, delivery: () => Effect.Effect, ) => @@ -1425,6 +1445,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); }; + // Chromium splits one search across several updates (the ordinal and the + // final count can land separately), so every update is forwarded. + const foundInPage = (_event: unknown, result: Electron.Result): void => { + runFork( + emitFindResult({ + tabId, + activeMatchOrdinal: result.activeMatchOrdinal, + matches: result.matches, + }), + ); + }; const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; @@ -1461,6 +1492,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function syncMenuShortcuts(window.webContents, input); }); }; + const findForwardedShortcut = (keys: Parameters[0]) => + !( + hostPlatform !== "darwin" && + keys.ctrlKey && + keys.altKey && + /^(?:[^a-zA-Z0-9]|Dead)$/u.test(keys.key) + ) && + forwardedShortcuts.find(({ shortcut }) => + matchesKeybindingShortcut( + keys, + shortcut, + hostPlatform === "darwin" ? "MacIntel" : hostPlatform, + ), + ); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { syncMenuShortcuts(wc, input); const host = wc.hostWebContents; @@ -1470,27 +1515,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function host && !host.isDestroyed() && holdsKeyboardFocus(wc) && - !( - hostPlatform !== "darwin" && - input.control && - input.alt && - /^(?:[^a-zA-Z0-9]|Dead)$/u.test(input.key) - ) && - forwardedShortcuts.find(({ shortcut }) => - matchesKeybindingShortcut( - { - key: input.key, - code: input.code, - metaKey: input.meta, - ctrlKey: input.control, - shiftKey: input.shift, - altKey: input.alt, - }, - shortcut, - hostPlatform === "darwin" ? "MacIntel" : hostPlatform, - ), - ); + findForwardedShortcut({ + key: input.key, + code: input.code, + metaKey: input.meta, + ctrlKey: input.control, + shiftKey: input.shift, + altKey: input.alt, + }); if (forwarded && host) { + // The page keeps these keys. Only the main-frame preload can report one it + // left unhandled, so a focused iframe (e.g. Google Docs' input) keeps its own find. + if (PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; event.preventDefault(); if (!input.isAutoRepeat) { if ( @@ -1515,6 +1551,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } }; + const pageUnhandledShortcut = (_event: unknown, rawKeys?: unknown): void => { + const host = wc.hostWebContents; + if (!host || host.isDestroyed() || !holdsKeyboardFocus(wc)) return; + const keys = decodePageShortcutKeys(rawKeys); + if (Option.isNone(keys)) return; + const forwarded = findForwardedShortcut(keys.value); + if (!forwarded || !PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; + host.send(MENU_ACTION_CHANNEL, forwarded.command); + }; yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { @@ -1529,11 +1574,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("dom-ready", restoreRecordingCursor); wc.off("did-fail-load", failed as never); wc.off("audio-state-changed", audioStateChanged); + wc.off("found-in-page", foundInPage as never); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + wc.ipc.off(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys(); }).pipe(Effect.ignore), ); @@ -1550,9 +1597,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("dom-ready", restoreRecordingCursor); wc.on("did-fail-load", failed as never); wc.on("audio-state-changed", audioStateChanged); + wc.on("found-in-page", foundInPage as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + wc.ipc.on(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { const action = previewWindowOpenAction(details); @@ -2052,6 +2101,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); const hardReload = (tabId: string) => withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); + const findInPage = ( + tabId: string, + text: string, + options: { readonly forward: boolean; readonly newSession: boolean }, + ) => + withWebContents("findInPage", tabId, (wc) => { + // Electron's `findNext` means "begin a new session", not "go to the next match". + wc.findInPage(text, { forward: options.forward, findNext: options.newSession }); + }); + const stopFindInPage = (tabId: string) => + withWebContents("stopFindInPage", tabId, (wc) => wc.stopFindInPage("clearSelection")).pipe( + Effect.catchTags({ + PreviewTabNotFoundError: () => Effect.void, + PreviewWebContentsNotFoundError: () => Effect.void, + }), + ); const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { const wc = yield* requireWebContents(tabId); @@ -3345,6 +3410,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { discard: true }, ); }); + const emitFindResult = Effect.fn("PreviewManager.emitFindResult")(function* ( + event: DesktopPreviewFindResultEvent, + ) { + const listeners = yield* Ref.get(findResultListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("find-result", event.tabId, () => listener(event)), + { discard: true }, + ); + }); const subscribe = ( ref: Ref.Ref>, listener: A, @@ -3367,6 +3442,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(openLinkListenersRef, new Set()), + Ref.set(findResultListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, @@ -3381,6 +3457,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function closeTab, copyArtifactToClipboard, createTab, + findInPage, goBack, goForward, hardReload, @@ -3410,10 +3487,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), startRecording, closePictureInPicture, + stopFindInPage, stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), + subscribeFindResults: (listener: FindResultListener) => + subscribe(findResultListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), @@ -3595,6 +3675,12 @@ export class PreviewManager extends Context.Service< readonly resetZoom: (tabId: string) => Effect.Effect; readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; + readonly findInPage: ( + tabId: string, + text: string, + options: { readonly forward: boolean; readonly newSession: boolean }, + ) => Effect.Effect; + readonly stopFindInPage: (tabId: string) => Effect.Effect; readonly setColorScheme: ( tabId: string, colorScheme: DesktopPreviewColorScheme, @@ -3659,6 +3745,9 @@ export class PreviewManager extends Context.Service< readonly subscribeOpenLinks: ( listener: OpenLinkListener, ) => Effect.Effect; + readonly subscribeFindResults: ( + listener: FindResultListener, + ) => Effect.Effect; readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; @@ -3760,6 +3849,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { resetZoom: operations.resetZoom, reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, + findInPage: operations.findInPage, + stopFindInPage: operations.stopFindInPage, setColorScheme: operations.setColorScheme, setZoomFactor: operations.setZoomFactor, setAudioMuted: operations.setAudioMuted, @@ -3808,6 +3899,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeOpenLinks: operations.subscribeOpenLinks, + subscribeFindResults: operations.subscribeFindResults, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index dcb16b851dc23..1d8c503d7edf8 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -33,6 +33,7 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_POINTER_CHANNEL, RECORDING_KEY_CHANNEL, @@ -204,6 +205,26 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); +// Capture runs before a page can stop the event; the task delay lets every page +// listener call preventDefault before the host is told the key went unhandled. +const reportUnhandledShortcut = (event: KeyboardEvent): void => { + if (!event.isTrusted || event.repeat || event.isComposing) return; + if (!event.metaKey && !event.ctrlKey && !event.altKey && !/^F\d{1,2}$/u.test(event.key)) return; + setTimeout(() => { + if (event.defaultPrevented) return; + ipcRenderer.send(PAGE_UNHANDLED_SHORTCUT_CHANNEL, { + key: event.key, + code: event.code, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + }); + }, 0); +}; + +window.addEventListener("keydown", reportUnhandledShortcut, true); + const MOUSE_BUTTON_BACK = 3; const MOUSE_BUTTON_FORWARD = 4; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2e65d62188df2..a72974250d051 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -80,6 +80,8 @@ import { UserPlusIcon, } from "lucide-react"; import { openThreadFind } from "./chat/threadFindStore"; +import { dispatchPreviewAction } from "./preview/previewActionBus"; +import { previewBridge } from "./preview/previewBridge"; import { useDeferredValue, useEffect, @@ -164,7 +166,11 @@ import { useCommandPaletteMount, useCommandPaletteStore, } from "../commandPaletteStore"; -import { PULL_REQUESTS_PANEL_REF, useRightPanelStore } from "../rightPanelStore"; +import { + PULL_REQUESTS_PANEL_REF, + selectActiveRightPanel, + useRightPanelStore, +} from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "@t3tools/client-runtime/state/thread-sort"; import { cn, @@ -676,6 +682,14 @@ function OpenCommandPaletteDialog(props: { const activeThreadKey = activeThread ? scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)) : null; + const previewPanelThreadRef = activeThread + ? scopeThreadRef(activeThread.environmentId, activeThread.id) + : activeDraftThread + ? scopeThreadRef(activeDraftThread.environmentId, activeDraftThread.threadId) + : null; + const activeThreadPreviewOpen = useRightPanelStore( + (state) => selectActiveRightPanel(state.byThreadKey, previewPanelThreadRef) === "preview", + ); const supportsThreadFind = environments.find((environment) => environment.environmentId === activeThread?.environmentId) ?.serverConfig?.threadFind === true; @@ -1343,6 +1357,20 @@ function OpenCommandPaletteDialog(props: { }); } + if (activeThreadPreviewOpen && previewBridge) { + actionItems.push({ + kind: "action", + value: "find-in-browser-page", + title: "Find on page in browser", + searchTerms: ["find", "search", "browser", "preview", "page"], + icon: , + shortcutCommand: "preview.find", + run: async () => { + dispatchPreviewAction("find"); + }, + }); + } + const promptLinkTarget = paletteRouteTarget ? paletteRouteTarget.kind === "server" ? paletteRouteTarget.threadRef diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index 77cc7b3d3068d..7ecc10110f1fc 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -50,6 +50,7 @@ const PREVIEW_FORWARDED_COMMANDS = [ "rightPanel.toggle", "rightPanel.toggleMaximized", "preview.focusUrl", + "preview.find", ] as const; const isGlobalPullRequests = (ref: ScopedThreadRef) => diff --git a/apps/web/src/components/preview/PreviewFindBar.tsx b/apps/web/src/components/preview/PreviewFindBar.tsx new file mode 100644 index 0000000000000..383cf19b6222c --- /dev/null +++ b/apps/web/src/components/preview/PreviewFindBar.tsx @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { cn } from "~/lib/utils"; +import { previewBridge } from "./previewBridge"; + +interface PreviewFindBarProps { + readonly tabId: string; + readonly focusRequestId: number; + readonly onClose: () => void; +} + +export function PreviewFindBar({ tabId, focusRequestId, onClose }: PreviewFindBarProps) { + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [result, setResult] = useState<{ active: number; matches: number } | null>(null); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses and selects the input DOM node when the bar opens or a find shortcut fires + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each find shortcut bumps the request id, so an already-open bar refocuses and reselects its query. + }, [focusRequestId]); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to Chromium find results for this tab and stops the search on unmount + useEffect(() => { + if (!previewBridge) return; + const unsubscribe = previewBridge.onFindResult((event) => { + if (event.tabId !== tabId) return; + // Chromium reports -1 for a field an update leaves unchanged. + setResult((previous) => ({ + active: event.activeMatchOrdinal >= 0 ? event.activeMatchOrdinal : (previous?.active ?? 0), + matches: event.matches >= 0 ? event.matches : (previous?.matches ?? 0), + })); + }); + return () => { + unsubscribe(); + void previewBridge?.stopFindInPage(tabId).catch(() => undefined); + }; + }, [tabId]); + + const search = (text: string, options: { forward: boolean; newSession: boolean }) => { + if (!previewBridge) return; + void previewBridge.findInPage(tabId, text, options).catch(() => undefined); + }; + + const handleQueryChange = (next: string) => { + setQuery(next); + if (next.length === 0) { + setResult(null); + void previewBridge?.stopFindInPage(tabId).catch(() => undefined); + return; + } + search(next, { forward: true, newSession: true }); + }; + + const step = (forward: boolean) => { + if (query.length === 0) return; + search(query, { forward, newSession: false }); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + step(!event.shiftKey); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }; + + const hasQuery = query.length > 0; + const noResults = hasQuery && result !== null && result.matches === 0; + const label = + hasQuery && result ? `${result.matches === 0 ? 0 : result.active}/${result.matches}` : ""; + const navigationDisabled = !result || result.matches === 0; + + return ( +
+ + handleQueryChange(event.target.value)} + onKeyDown={handleKeyDown} + /> + + + {label} + + + + + + +
+ ); +} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 3e0f6298ce8d2..963c74a6743df 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -6,6 +6,7 @@ import { Menu, MenuItem, MenuPopup, + MenuShortcut, MenuRadioGroup, MenuRadioItem, MenuGroup, @@ -37,6 +38,7 @@ export interface PreviewMoreMenuActions { readonly clearCache: () => void; readonly openDevTools?: () => void; readonly toggleNativePictureInPicture?: () => void; + readonly find?: () => void; } interface Props { @@ -48,6 +50,7 @@ interface Props { onToggleDeviceToolbar: () => void; nativePictureInPicture: boolean; profileName: string | undefined; + findShortcutLabel?: string | null; move?: { readonly label: string; readonly onMove: () => void }; } @@ -75,6 +78,7 @@ export function PreviewMoreMenu({ onToggleDeviceToolbar, nativePictureInPicture, profileName, + findShortcutLabel, move, }: Props) { const zoomLabel = `${Math.round(zoomFactor * 100)}%`; @@ -83,6 +87,12 @@ export function PreviewMoreMenu({
+ {actions.find ? ( + + Find on page + {findShortcutLabel ? {findShortcutLabel} : null} + + ) : null} Hard reload diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 1f1494fa2736d..72b4d04f5b516 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -73,6 +73,9 @@ vi.mock(import("./previewBridge"), async (original) => ({ open: mocks.openPictureInPicture, close: mocks.closePictureInPicture, }, + findInPage: vi.fn(async () => undefined), + stopFindInPage: vi.fn(async () => undefined), + onFindResult: vi.fn(() => () => undefined), } as never, })); vi.mock(import("~/browserHistoryStore"), async (original) => ({ @@ -388,4 +391,21 @@ describe("PreviewView", () => { act(() => dispatchPreviewAction("focus-url")); await expect.element(address).toHaveFocus(); }); + + it("does not reopen find when the user returns to a tab they left with find open", async () => { + setup(); + const tabView = (tabId: string) => ( + + ); + await using screen = await render(tabView("tab-1")); + const findBar = page.getByRole("search", { name: "Find in page" }); + + act(() => dispatchPreviewAction("find")); + await expect.element(findBar).toBeInTheDocument(); + + await screen.rerender(tabView("tab-2")); + await expect.element(findBar).not.toBeInTheDocument(); + await screen.rerender(tabView("tab-1")); + await expect.element(findBar).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 9225e61afeaf9..33fd2fa8511b4 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -59,7 +59,7 @@ import { import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useRightPanelStore } from "~/rightPanelStore"; -import { effectiveShortcutsForCommand } from "~/keybindings"; +import { effectiveShortcutsForCommand, shortcutLabelForCommand } from "~/keybindings"; import { randomUUID } from "~/lib/utils"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { PreviewAnnotationSpeech } from "./PreviewAnnotationSpeech"; @@ -90,6 +90,7 @@ import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { usePreviewSession } from "./usePreviewSession"; import { ZoomIndicator } from "./ZoomIndicator"; import { AgentBrowserCursor } from "./AgentBrowserCursor"; +import { PreviewFindBar } from "./PreviewFindBar"; import { findActiveBrowserRecordingRuntimeTabId, isBrowserRecordingStartCancelledError, @@ -145,6 +146,14 @@ function ServerTabFileNotShown(props: { ); } +function focusPreviewPage(runtimeTabId: string) { + const active = document.activeElement; + if (active && active !== document.body && !active.closest("[data-preview-panel-mode]")) return; + document + .querySelector(`[data-preview-viewport="${CSS.escape(runtimeTabId)}"] webview`) + ?.focus(); +} + export function PreviewView({ threadRef, tabId: requestedTabId, @@ -153,6 +162,7 @@ export function PreviewView({ onSendAnnotation, }: Props) { const urlInputRef = useRef(null); + const [find, setFind] = useState<{ tabId: string; requestId: number } | null>(null); const [pickActive, setPickActive] = useState(false); const canSendAnnotation = useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) && @@ -186,6 +196,7 @@ export function PreviewView({ const runtimeTabId = tabId ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId) : null; + if (find !== null && find.tabId !== runtimeTabId) setFind(null); const recordingRuntimeTabId = tabId && runtimeTabId ? activeRecordingTabIds.has(runtimeTabId) @@ -247,6 +258,7 @@ export function PreviewView({ if (runtimeTabId && previewBridge) { await previewBridge.navigate(runtimeTabId, resolvedUrl); rememberPreviewUrl(threadRef, resolvedUrl); + focusPreviewPage(runtimeTabId); return true; } const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); @@ -342,6 +354,27 @@ export function PreviewView({ if (next !== serverZoomFactor) void adjustServerTab({ zoomFactor: next }); }; + const canFindInPage = + previewBridge !== null && + runtimeTabId !== null && + desktopOverlay !== null && + !isServerTab && + !showEmptyState && + !isUnreachable; + const handleFind = () => { + if (!canFindInPage) { + toastManager.add({ type: "info", title: "Find on page isn't available for this page" }); + return; + } + setFind((previous) => ({ tabId: runtimeTabId, requestId: (previous?.requestId ?? 0) + 1 })); + }; + + // Otherwise focus falls to the body and the next find shortcut misses the preview. + const closeFind = () => { + setFind(null); + if (runtimeTabId) focusPreviewPage(runtimeTabId); + }; + const handleZoomIn = () => { if (serverOwnsRendering) stepServerZoom(1); else if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId); @@ -455,6 +488,7 @@ export function PreviewView({ ? { openDevTools: desktopCall(previewBridge.openDevTools), toggleNativePictureInPicture: () => handleNativePictureInPicture(), + ...(canFindInPage ? { find: handleFind } : {}), } : {}), } @@ -478,6 +512,7 @@ export function PreviewView({ .catch(() => undefined), openDevTools: desktopCall(previewBridge.openDevTools), toggleNativePictureInPicture: () => handleNativePictureInPicture(), + ...(canFindInPage ? { find: handleFind } : {}), } : null; @@ -872,17 +907,23 @@ export function PreviewView({ case "reset-zoom": handleResetZoom(); return; + case "find": + handleFind(); + return; case "toggle-panel": return; } }, ); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to preview actions while the view is visible + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to preview and desktop menu actions while the view is visible useEffect(() => { if (!visible) return; const unsubscribeAction = subscribePreviewAction((action) => onPreviewAction(action)); + // A focused page swallows its own keydowns, so the desktop forwards these + // shortcuts from the guest as menu actions instead. const unsubscribeMenu = window.desktopBridge?.onMenuAction((action) => { if (action === "preview.focusUrl") onPreviewAction("focus-url"); + if (action === "preview.find") onPreviewAction("find"); }); return () => { unsubscribeAction(); @@ -955,6 +996,9 @@ export function PreviewView({ ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } } : {})} profileName={activeProfileName} + findShortcutLabel={shortcutLabelForCommand(keybindings, "preview.find", { + context: { previewFocus: true }, + })} zoomFactor={ serverOwnsRendering ? serverZoomFactor : (desktopOverlay?.zoomFactor ?? 1) } @@ -1059,6 +1103,14 @@ export function PreviewView({ controller={controller} /> ) : null} + {find && canFindInPage && find.tabId === runtimeTabId ? ( + + ) : null} {navStatus._tag === "LoadFailed" && navStatus.download ? (
void { "preview.zoomIn": previewCommand("zoom-in"), "preview.zoomOut": previewCommand("zoom-out"), "preview.resetZoom": previewCommand("reset-zoom"), + "preview.find": previewCommand("find"), }, { when: () => diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 5eb5a4c96d514..0449990ee0b2b 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -153,6 +153,13 @@ update an older server to enable it. Select **Retry** if a search fails. It defa `mod+f` outside terminals and previews. Press **Enter** or **Shift+Enter** to move between matches, and **Escape** to close find. +## Find on a browser page + +`preview.find` (`mod+f` while a browser has focus) searches the open page in the desktop app. It +is also in the browser's **⋮** menu and the command palette. A page that handles `mod+f` itself, +such as a code editor, keeps its own find. Use the **⋮** menu to reach the browser's find there. +Streamed browser tabs and T3 Code in a web browser do not support it. + ## Precedence The last rule whose key and condition both match wins, even if it belongs to a diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 39f96754a636f..9bfb1917b470b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -635,6 +635,12 @@ export interface DesktopPreviewOpenLinkEvent { background: boolean; } +export interface DesktopPreviewFindResultEvent { + tabId: string; + activeMatchOrdinal: number; + matches: number; +} + export const DesktopPreviewRecordingInputSchema = Schema.Union([ Schema.Struct({ type: Schema.Literal("pointer"), @@ -993,6 +999,13 @@ export const DesktopPreviewSetZoomFactorInputSchema = Schema.Struct({ zoomFactor: Schema.Number.check(Schema.isGreaterThan(0)), }); +export const DesktopPreviewFindInPageInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + text: Schema.NonEmptyString.check(Schema.isMaxLength(1000)), + forward: Schema.Boolean, + newSession: Schema.Boolean, +}); + export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, audioMuted: Schema.Boolean, @@ -1253,6 +1266,12 @@ export interface DesktopPreviewBridge { hardReload: (tabId: string) => Promise; setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; + findInPage: ( + tabId: string, + text: string, + options: { forward: boolean; newSession: boolean }, + ) => Promise; + stopFindInPage: (tabId: string) => Promise; /** Tells the tab's server whether its webview is on screen. */ setShown: (tabId: string, shown: boolean) => Promise; openDevTools: (tabId: string) => Promise; @@ -1305,6 +1324,7 @@ export interface DesktopPreviewBridge { onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void; onOpenLink: (listener: (event: DesktopPreviewOpenLinkEvent) => void) => () => void; + onFindResult: (listener: (event: DesktopPreviewFindResultEvent) => void) => () => void; } export type ConfirmDialogVariant = "default" | "destructive"; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 8c9e35afcda10..3338c1a456002 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -80,6 +80,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomIn", "preview.zoomOut", "preview.resetZoom", + "preview.find", "commandPalette.toggle", "filePicker.toggle", "projectSearch.toggle", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index ba4d1d925be48..9a67264d8bd1f 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -82,6 +82,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod++", command: "preview.zoomIn", when: "previewFocus" }, { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, + { key: "mod+f", command: "preview.find", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, From f6433dba45ae0022f17513e2c0cfc76a632eb835 Mon Sep 17 00:00:00 2001 From: "M.Umais Adeed" <165276382+Umais-Adeed@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:20:00 +0500 Subject: [PATCH 14/56] fix(desktop): an SSH password prompt that ended leaves the dialog instead of stacking (#34192) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/preload.ts | 11 ++++ .../src/ssh/DesktopSshPasswordPrompts.test.ts | 51 +++++++++++++++- .../src/ssh/DesktopSshPasswordPrompts.ts | 19 +++++- .../SshPasswordPromptDialog.browser.test.tsx | 61 +++++++++++++++---- .../desktop/SshPasswordPromptDialog.tsx | 19 ++++-- packages/contracts/src/ipc.ts | 2 + 7 files changed, 144 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 09f79cfc82f76..33dd9e272df7d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -70,6 +70,7 @@ export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-beare export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; +export const SSH_PASSWORD_PROMPT_SETTLED_CHANNEL = "desktop:ssh-password-prompt-settled"; export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 84f3af63631f4..5bf05668b5a98 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -195,6 +195,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); }; }, + onSshPasswordPromptSettled: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, requestId: unknown) => { + if (typeof requestId !== "string") return; + listener(requestId); + }; + + ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, wrappedListener); + }; + }, resolveSshPasswordPrompt: (requestId, password) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index c0f8210f18146..1e5b6dafc6cae 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -8,7 +8,10 @@ import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import { + SSH_PASSWORD_PROMPT_CHANNEL, + SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, +} from "../ipc/channels.ts"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; interface SentMessage { @@ -144,6 +147,52 @@ describe("DesktopSshPasswordPrompts", () => { }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); }); + it.effect.each(["answered", "cancelled", "timed out", "interrupted"] as const)( + "tells the window a prompt ended when it is %s", + (outcome) => { + const testWindow = makeTestWindow(); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const fiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.exit, Effect.forkScoped); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + const request = testWindow.sentMessages[0]?.args[0] as { readonly requestId: string }; + assert.ok(request); + switch (outcome) { + case "answered": + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + break; + case "cancelled": + yield* prompts.resolve({ requestId: request.requestId, password: null }); + break; + case "timed out": + yield* TestClock.adjust("1 second"); + break; + case "interrupted": + yield* Fiber.interrupt(fiber); + break; + } + yield* Fiber.await(fiber); + + assert.deepEqual( + testWindow.sentMessages + .filter((message) => message.channel === SSH_PASSWORD_PROMPT_SETTLED_CHANNEL) + .map((message) => message.args), + [[request.requestId]], + ); + }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }, + ); + it.effect("keeps a submitted password when a later presentation step fails", () => { const testWindow = makeTestWindow({ isMinimizedError: new Error("failed to read minimized state"), diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 3c16b938331ca..5fccc78d9a918 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -13,7 +13,10 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import { + SSH_PASSWORD_PROMPT_CHANNEL, + SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, +} from "../ipc/channels.ts"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; @@ -38,6 +41,7 @@ const DesktopSshPromptPresentationOperation = Schema.Literals([ "check-window-after-restore", "focus-window", "remove-window-close-listener", + "send-prompt-settled", ]); type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type; @@ -371,11 +375,22 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( cause, }), }); + // However the request ends, the window drops its prompt, so no ended prompt stays on screen. + const notifySettled = runPresentationOperation("send-prompt-settled", () => { + if (!window.value.isDestroyed()) { + window.value.webContents.send(SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, requestId); + } + }).pipe(Effect.ignore); const cleanup = runPresentationOperation("remove-window-close-listener", () => { if (!window.value.isDestroyed()) { window.value.removeListener("closed", cancelOnWindowClosed); } - }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid); + }).pipe( + Effect.orDie, + Effect.ensuring(removePending(pendingRef, requestId)), + Effect.ensuring(notifySettled), + Effect.asVoid, + ); const waitForPassword = Deferred.await(deferred).pipe( Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), Effect.flatMap( diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.browser.test.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.browser.test.tsx index 53eaec1822253..c4f66b1918a65 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.browser.test.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.browser.test.tsx @@ -4,28 +4,67 @@ import { expect, it } from "vite-plus/test"; import { render, stubGlobal } from "../../../test/render"; import { SshPasswordPromptDialog, startSshPasswordPromptQueue } from "./SshPasswordPromptDialog"; -it("shows a prompt that arrived before the dialog mounted", async () => { +function stubPromptBridge() { const listeners: Array<(request: DesktopSshPasswordPromptRequest) => void> = []; - using _bridge = stubGlobal("desktopBridge", { + const settledListeners: Array<(requestId: string) => void> = []; + const bridge = stubGlobal("desktopBridge", { onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => { listeners.push(listener); return () => listeners.splice(listeners.indexOf(listener), 1); }, + onSshPasswordPromptSettled: (listener: (requestId: string) => void) => { + settledListeners.push(listener); + return () => settledListeners.splice(settledListeners.indexOf(listener), 1); + }, resolveSshPasswordPrompt: async () => undefined, }); + return { + prompt: (requestId: string, destination: string) => { + for (const listener of listeners) { + listener({ + requestId, + destination, + username: "julius", + prompt: `julius@${destination}'s password:`, + expiresAt: "2999-01-01T00:00:00.000Z", + }); + } + }, + settle: (requestId: string) => { + for (const listener of settledListeners) listener(requestId); + }, + [Symbol.dispose]: () => bridge[Symbol.dispose](), + }; +} + +it("drops a prompt the desktop ended, whether it is on screen or still waiting", async () => { + using bridge = stubPromptBridge(); + const stopQueue = startSshPasswordPromptQueue(); + using _queue = { [Symbol.dispose]: stopQueue }; + await using screen = await render(); + + bridge.prompt("ssh-1", "first-host"); + bridge.prompt("ssh-1", "first-host"); + bridge.prompt("ssh-2", "second-host"); + bridge.prompt("ssh-3", "third-host"); + await expect.element(screen.getByText("julius@first-host")).toBeVisible(); + + // ssh-2 ended while it waited behind ssh-1, and ssh-1 ended on screen. + bridge.settle("ssh-2"); + bridge.settle("ssh-1"); + await expect.element(screen.getByText("julius@third-host")).toBeVisible(); + + bridge.settle("ssh-3"); + await expect.element(screen.getByText("SSH verification required")).not.toBeInTheDocument(); +}); + +it("shows a prompt that arrived before the dialog mounted", async () => { + using bridge = stubPromptBridge(); const stopQueue = startSshPasswordPromptQueue(); using _queue = { [Symbol.dispose]: stopQueue }; // The desktop asks while the app is still on a page without the dialog (pairing, sign-in). - for (const listener of listeners) { - listener({ - requestId: "ssh-1", - destination: "devbox", - username: "julius", - prompt: "julius@devbox's password:", - expiresAt: "2999-01-01T00:00:00.000Z", - }); - } + bridge.prompt("ssh-1", "devbox"); await using screen = await render(); await expect.element(screen.getByText("SSH verification required")).toBeVisible(); diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx index e09836a378c60..bc97e65c7f995 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -47,11 +47,17 @@ function setPromptQueue(next: readonly DesktopSshPasswordPromptRequest[]) { * that arrives while no dialog is mounted still waits for one. */ export function startSshPasswordPromptQueue(): () => void { - return ( - window.desktopBridge?.onSshPasswordPrompt((request) => { - setPromptQueue([...promptQueue, request]); - }) ?? (() => undefined) - ); + const bridge = window.desktopBridge; + if (!bridge) return () => undefined; + const stopPrompts = bridge.onSshPasswordPrompt((request) => { + if (promptQueue.some((queued) => queued.requestId === request.requestId)) return; + setPromptQueue([...promptQueue, request]); + }); + const stopSettled = bridge.onSshPasswordPromptSettled(removeSshPasswordPrompt); + return () => { + stopPrompts(); + stopSettled(); + }; } function subscribePromptQueue(listener: () => void): () => void { @@ -64,7 +70,8 @@ function subscribePromptQueue(listener: () => void): () => void { const readPromptQueue = () => promptQueue; function removeSshPasswordPrompt(requestId: string) { - if (promptQueue[0]?.requestId === requestId) setPromptQueue(promptQueue.slice(1)); + if (!promptQueue.some((request) => request.requestId === requestId)) return; + setPromptQueue(promptQueue.filter((request) => request.requestId !== requestId)); } export function SshPasswordPromptDialog() { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 9bfb1917b470b..57c5afdf431d5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1110,6 +1110,8 @@ export interface DesktopBridge { ) => Promise; fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise; onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void; + /** Fires with a prompt's request id once it is answered, cancelled, expired or abandoned. */ + onSshPasswordPromptSettled: (listener: (requestId: string) => void) => () => void; resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; From 2c74ea6915e4f8013992816697c4b27905e88e7e Mon Sep 17 00:00:00 2001 From: maco Date: Wed, 11 Nov 2026 11:25:16 +0100 Subject: [PATCH 15/56] fix(web): render Claude insight blocks as a callout (#22239) Co-authored-by: macodev00 Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../components/ChatMarkdown.browser.test.tsx | 34 ++++++ apps/web/src/components/ChatMarkdown.tsx | 21 +++- .../components/chat/TimelineAssistantRows.tsx | 1 + .../chat/markdownStreamBlocks.test.tsx | 4 +- .../components/chat/markdownStreamBlocks.ts | 15 ++- packages/shared/src/markdownPipeline.test.ts | 109 ++++++++++++++++++ packages/shared/src/markdownPipeline.ts | 78 +++++++++++++ packages/shared/src/threadFindText.test.ts | 12 +- packages/shared/src/threadFindText.ts | 32 +++-- 9 files changed, 291 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index b03612507f917..59460d83a0332 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -481,6 +481,40 @@ describe("ChatMarkdown skill chips", () => { }); describe("ChatMarkdown headings", () => { + it("keeps insight streaming, reference links, and heading anchors in one message", async () => { + setup(); + const rule = "─".repeat(20); + const text = `## Setup\n\nBefore [source][src].\n\n★ Insight ${rule}\nA **streaming** note.`; + const message = (text: string, isStreaming: boolean) => ( + + ); + await using screen = await render(message(text, true)); + await expect.element(screen.getByRole("note")).toHaveTextContent("Insight A streaming note."); + await screen.rerender( + message( + `${text}\n${rule}\n\n## Setup\n\nAfter [source][src].\n\n[src]:\n https://example.com`, + false, + ), + ); + await expect.element(screen.getByRole("note")).toHaveTextContent("Insight A streaming note."); + expect([...screen.container.querySelectorAll("h2")].map((heading) => heading.id)).toEqual([ + "user-content-setup", + "user-content-setup-1", + ]); + expect( + [...screen.container.querySelectorAll("a")].filter( + (link) => link.href === "https://example.com/", + ), + ).toHaveLength(2); + await expect.element(screen.getByRole("note")).not.toHaveTextContent("After"); + }); + it("never gives two headings the same id, even when a suffix matches another heading", async () => { setup(); await using screen = await renderText( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0b0116be549cb..140a35526f4f0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -19,6 +19,7 @@ import { CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, CHAT_MARKDOWN_REHYPE_PLUGINS, + remarkAssistantInsights, } from "@t3tools/shared/markdownPipeline"; import { useAtomValue } from "@effect/atom-react"; import { @@ -37,6 +38,7 @@ import { MessageSquareWarningIcon, OctagonAlertIcon, PresentationIcon, + StarIcon, TriangleAlertIcon, type LucideIcon, } from "lucide-react"; @@ -224,6 +226,7 @@ import { reportMarkdownActionFailure } from "./chat/markdownActionFailure"; const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError); interface ChatMarkdownProps { + renderInsights?: boolean; /** Compact markdown transforms remove blocks before rendering inside a label. */ inline?: boolean; text: string; @@ -342,6 +345,12 @@ const GITHUB_ALERT_PRESENTATIONS: Record< string, { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } > = { + insight: { + label: "Insight", + Icon: StarIcon, + borderClassName: "border-info", + titleClassName: "text-info", + }, note: { label: "Note", Icon: InfoIcon, @@ -1680,6 +1689,8 @@ const CHAT_MARKDOWN_REMARK_PLUGIN_SETS = { const combinedPlugins = new WeakMap>(); // One combined list per pair, so a stable extra list shares a renderer and its parse cache. +const INSIGHT_REMARK_PLUGINS: PluggableList = [remarkAssistantInsights]; + function withExtraPlugins(shared: PluggableList, extra: PluggableList): PluggableList { if (extra.length === 0) return shared; let byShared = combinedPlugins.get(extra); @@ -1746,10 +1757,12 @@ function ChatMarkdown({ lineBreaks = false, parseRawHtml = true, extraRemarkPlugins = EMPTY_REMARK_PLUGINS, + renderInsights = false, ...props }: ChatMarkdownProps) { const { componentState, handleCopy, markdownRef, localMediaPreview, closeLocalMediaPreview } = useChatMarkdownState({ text, inline, ...props }); + const hasInsights = renderInsights && text.includes("★ Insight"); // Streaming re-renders only the last block; offsets for task edits need the whole text. const streamingBlocks = props.isStreaming === true && @@ -1760,6 +1773,7 @@ function ChatMarkdown({ : null; const incrementalParsing = props.isStreaming === true && + !hasInsights && streamingBlocks === null && extraRemarkPlugins.length === 0 && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); @@ -1781,7 +1795,12 @@ function ChatMarkdown({ const uncachedPlugins = incrementalParsing; const remarkPlugins = incrementalParsing ? [...sharedRemarkPlugins, ...extraRemarkPlugins, createIncrementalMarkdownPlugin()] - : withExtraPlugins(sharedRemarkPlugins, extraRemarkPlugins); + : withExtraPlugins( + hasInsights + ? withExtraPlugins(INSIGHT_REMARK_PLUGINS, sharedRemarkPlugins) + : sharedRemarkPlugins, + extraRemarkPlugins, + ); const blocks = streamingBlocks ?? [text]; return ( diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 6756220a990cd..732efb40897f0 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -49,6 +49,7 @@ export function AssistantTimelineRow({ row }: { row: Extract renderToStaticMarkup( {source} @@ -33,6 +34,7 @@ describe("splitMarkdownBlocks", () => { "Setext\n===\n\nnext\n---\n\n- [ ] task\n- [x] done", "- a\n\n ```\n x\n\n```\n\nb", "- a\n\n ```\n x\n\ny\n\n ```\n\nb", + "intro\n\n★ Insight ─────\nfirst\n\n```\n─────\n```\n\n- point\n─────\n\nafter\n\n`★ Insight ─────`\nsecond\n`─────`\n\nend", ])("renders each block as the whole text renders it, at every streamed prefix: %j", (text) => { for (let end = 0; end <= text.length; end++) { const source = text.slice(0, end); diff --git a/apps/web/src/components/chat/markdownStreamBlocks.ts b/apps/web/src/components/chat/markdownStreamBlocks.ts index f0435f2e19590..475e49666c392 100644 --- a/apps/web/src/components/chat/markdownStreamBlocks.ts +++ b/apps/web/src/components/chat/markdownStreamBlocks.ts @@ -3,6 +3,9 @@ const DOCUMENT_WIDE_SYNTAX = /^ {0,3}(?:\[[^\]\n]+\]:|<[A-Za-z!?/]|:::|\$\$)|\[\ const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/; // After a blank line these still continue the previous block: a list item or indented content. const CONTINUATION_PATTERN = /^(?:[ \t]|(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$))/; +// Claude insight fences render as one callout, so they stay in one block until the closing rule. +const INSIGHT_OPEN_PATTERN = /^( {0,3})(`?)★ Insight[ \t]+─{3,}\2[ \t]*$/u; +const INSIGHT_CLOSE_PATTERN = /^( {0,3})(`?)─{3,}\2[ \t]*$/u; /** * Cuts Markdown at blank lines that close every open block, so each piece renders exactly as it @@ -16,6 +19,7 @@ export function splitMarkdownBlocks(text: string): string[] | null { let fence: string | null = null; let fenceIndent = 0; let afterBlankLine = false; + let insightIndent: string | null = null; for (const line of text.split("\n")) { const opener = FENCE_PATTERN.exec(line)?.[1]; if (fence !== null) { @@ -29,14 +33,23 @@ export function splitMarkdownBlocks(text: string): string[] | null { ) { fence = null; } + } else if (insightIndent !== null && INSIGHT_CLOSE_PATTERN.exec(line)?.[1] === insightIndent) { + insightIndent = null; + afterBlankLine = false; } else if (line.trim() === "") { afterBlankLine = true; } else { - if (afterBlankLine && lineStart > blockStart && !CONTINUATION_PATTERN.test(line)) { + if ( + afterBlankLine && + insightIndent === null && + lineStart > blockStart && + !CONTINUATION_PATTERN.test(line) + ) { blocks.push(text.slice(blockStart, lineStart)); blockStart = lineStart; } afterBlankLine = false; + if (insightIndent === null) insightIndent = INSIGHT_OPEN_PATTERN.exec(line)?.[1] ?? null; fence = opener ?? null; if (opener) fenceIndent = line.indexOf(opener); } diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index 3d4e69be16d9e..327e2f99d0433 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -8,6 +8,7 @@ import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, + remarkAssistantInsights, remarkEnableOrderedListNumbers, } from "./markdownPipeline.ts"; @@ -119,6 +120,114 @@ it.each([ } }); +const insightRule = "─".repeat(20); +const insightFence = (body: string, wrap = "") => + [`${wrap}★ Insight ${insightRule}${wrap}`, body, `${wrap}${insightRule}${wrap}`].join("\n"); + +it.each(["", "`"])("groups %j insight fences while preserving surrounding markdown", (wrap) => { + const markdown = [ + "Before **bold** and [reference][src].", + "", + insightFence("- Also [reference][src].", wrap), + "", + "After.", + "", + '[src]:\n https://example.com\n "Source"', + ].join("\n"); + const processor = unified() + .use(remarkParse) + .use(CHAT_MARKDOWN_REMARK_PLUGINS) + .use(remarkAssistantInsights) + .use(remarkRehype) + .use(CHAT_MARKDOWN_REHYPE_PLUGINS); + const tree = processor.runSync(processor.parse(markdown), markdown); + expect( + tree.children.filter((node) => node.type === "element").map((node) => node.tagName), + ).toEqual(["p", "blockquote", "p"]); + expect( + tree.children.find((node) => node.type === "element" && node.tagName === "blockquote"), + ).toMatchObject({ properties: { dataAlert: "insight" } }); + expect(JSON.stringify(tree).match(/"href":"https:\/\/example.com"/g)).toHaveLength(2); + expect(JSON.stringify(tree)).toContain('"tagName":"strong"'); + expect(JSON.stringify(tree)).not.toContain(insightRule); +}); + +it.each([ + ["```text", "★ Insight " + insightRule, "literal", insightRule, "```"].join("\n"), + ["- ```text", " ★ Insight " + insightRule, " literal", " " + insightRule, " ```"].join("\n"), + [" ★ Insight " + insightRule, " literal", " " + insightRule].join("\n"), + ["Paragraph.", " ★ Insight " + insightRule, " literal", " " + insightRule].join("\n"), + ["`example", "★ Insight " + insightRule, "literal", insightRule, "`"].join("\n"), + insightFence("quoted") + .split("\n") + .map((line) => "> " + line) + .join("\n"), +])("leaves literal and nested insight examples unchanged: %s", (markdown) => { + const processor = unified().use(remarkParse).use(remarkAssistantInsights); + const tree = processor.parse(markdown); + expect(processor.runSync(structuredClone(tree), markdown)).toEqual(tree); +}); + +it("groups streaming bodies and several insights without losing code or headings", () => { + const markdown = [ + "## Setup", + "", + insightFence("```text\n" + insightRule + "\n```"), + "", + "## Setup", + "", + "★ Insight " + insightRule, + "Still **streaming**", + ].join("\n"); + const processor = unified().use(remarkParse).use(remarkAssistantInsights); + const tree = processor.parse(markdown); + processor.runSync(tree, markdown); + expect(tree.children.map((node) => node.type)).toEqual([ + "heading", + "blockquote", + "heading", + "blockquote", + ]); + expect(tree.children[1]).toMatchObject({ children: [{ type: "code", value: insightRule }] }); + expect(tree.children[3]).toMatchObject({ + children: [ + { type: "paragraph", children: [{ type: "text", value: "Still " }, { type: "strong" }] }, + ], + }); +}); + +it("keeps reference definitions out of unfinished streaming code", () => { + const markdown = `[src]: https://example.com\n\n★ Insight ${insightRule}\n\`\`\`ts\nconst value = 1`; + const processor = unified().use(remarkParse).use(remarkAssistantInsights); + const tree = processor.parse(markdown); + processor.runSync(tree, markdown); + expect(tree.children.find((node) => node.type === "blockquote")).toMatchObject({ + children: [{ type: "code", value: "const value = 1" }], + }); +}); + +it("preserves footnotes and original code positions across a callout", () => { + const code = "```sh\necho hello\n```"; + const markdown = ["Before[^n].", "", insightFence(code), "", "[^n]: Footnote."].join("\n"); + const processor = unified() + .use(remarkParse) + .use(remarkAssistantInsights) + .use(CHAT_MARKDOWN_REMARK_PLUGINS); + const tree = processor.parse(markdown); + processor.runSync(tree, markdown); + expect(tree.children[0]).toMatchObject({ + children: [ + { type: "text", value: "Before" }, + { type: "footnoteReference", identifier: "n" }, + { type: "text", value: "." }, + ], + }); + expect(tree.children.at(-1)).toMatchObject({ type: "footnoteDefinition", identifier: "n" }); + const note = tree.children.find((node) => node.type === "blockquote"); + const block = note?.children[0]; + expect(markdown.slice(block?.position?.start.offset, block?.position?.end.offset)).toBe(code); +}); + it("preserves in-app thread links through the shared Markdown sanitizer", () => { const processor = unified() .use(remarkParse) diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 6a49d0023b0a2..048a51dae22d6 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -495,6 +495,84 @@ export function shouldPreserveAssistantLineBreaks(text: string): boolean { return /^★ Insight(?:\s|─)/mu.test(text); } +export function remarkAssistantInsights(this: Processor) { + return (tree: Root, file: { value: unknown }) => { + const source = String(file.value); + if (!source.includes("★ Insight")) return; + const protectedRanges: { start: number; end: number; code: boolean }[] = []; + const inspect = (node: RootContent | PhrasingContent) => { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start !== undefined && end !== undefined) { + const code = + node.type === "code" || + node.type === "html" || + (node.type === "inlineCode" && node.position?.start.line !== node.position?.end.line); + if (code || node.type === "list" || node.type === "blockquote") + protectedRanges.push({ start, end, code }); + } + if ("children" in node) node.children.forEach(inspect); + }; + tree.children.forEach(inspect); + const starts = new Set(); + const offsets: { original: number; rewritten: number; prefix: number }[] = []; + let indent: string | undefined; + let original = 0; + let rewritten = 0; + const lines = source.split(/(?<=\n)|(?<=\r)(?!\n)/u).map((line, index) => { + const value = line.replace(/[\r\n]+$/u, ""); + const blocked = protectedRanges.some( + (range) => + original >= range.start && original < range.end && (indent === undefined || range.code), + ); + const opening = + indent === undefined && !blocked + ? /^( {0,3})(`?)★ Insight[ \t]+─{3,}\2[ \t]*$/u.exec(value) + : null; + const closing = + indent !== undefined && !blocked ? /^( {0,3})(`?)─{3,}\2[ \t]*$/u.exec(value) : null; + let prefix = 0; + let output = line; + if (opening) { + indent = opening[1] ?? ""; + starts.add(index + 1); + output = ">" + line.slice(value.length); + } else if (closing && closing[1] === indent) { + indent = undefined; + output = line.slice(value.length); + } else if (indent !== undefined) { + prefix = 2; + output = "> " + line; + } + offsets.push({ original, rewritten, prefix }); + original += line.length; + rewritten += output.length; + return output; + }); + if (starts.size === 0) return; + const parsed = this.parse(lines.join("")) as Root; + const restorePosition = (node: RootContent | PhrasingContent) => { + if (node.position) { + for (const point of [node.position.start, node.position.end]) { + const offset = offsets[point.line - 1]; + if (!offset) continue; + point.column = Math.max(1, point.column - offset.prefix); + if (point.offset !== undefined) + point.offset += offset.original - offset.rewritten - offset.prefix; + } + } + if ("children" in node) node.children.forEach(restorePosition); + }; + for (const node of parsed.children) { + if (node.type === "blockquote" && starts.has(node.position?.start.line ?? 0)) { + node.data = { ...node.data, hProperties: { dataAlert: "insight" } }; + } + restorePosition(node); + } + tree.children = parsed.children; + }; +} + const imageSourceProcessor = unified() .use(remarkParse) .use(CHAT_MARKDOWN_REMARK_PLUGINS) diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index 77a0874c21336..0023946b5b9fa 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { expect, it } from "vite-plus/test"; import { serializeAssistantCitation } from "./assistantCitations.ts"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./composerContextReferences.ts"; -import { searchableMessageSegments } from "./threadFindText.ts"; +import { searchableMessageSegments, searchablePlanSegments } from "./threadFindText.ts"; it("excludes review attachments rendered as context chips", () => { const text = [ @@ -117,3 +117,13 @@ it("indexes the citation chip label instead of its link text", () => { "my note", ]); }); + +it("indexes insight bodies without hidden fences and keeps user and plan text literal", () => { + const text = "★ Insight ─────\nVisible body.\n─────"; + expect(assistantSegments(text)).toEqual(["Visible body."]); + expect(searchableMessageSegments({ role: "user", text, streaming: false })?.join("\n")).toContain( + "★ Insight", + ); + expect(searchablePlanSegments(text).join("\n")).toContain("★ Insight"); + expect(assistantSegments(`\`\`\`text\n${text}\n\`\`\``)?.join("\n")).toContain("★ Insight"); +}); diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index 3afec6e04729e..b120a0f0a4d49 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -33,6 +33,7 @@ import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { shouldPreserveAssistantLineBreaks, + remarkAssistantInsights, CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, @@ -78,16 +79,18 @@ export const THREAD_FIND_BLOCK_TAGS = new Set([ "ul", ]); -const assistantProcessor = unified() - .use(remarkParse) - .use(CHAT_MARKDOWN_REMARK_PLUGINS) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(CHAT_MARKDOWN_REHYPE_PLUGINS); -const assistantBreaksProcessor = unified() - .use(remarkParse) - .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(CHAT_MARKDOWN_REHYPE_PLUGINS); +function assistantTextProcessor(lineBreaks = false, renderInsights = false) { + return unified() + .use(remarkParse) + .use(renderInsights ? [remarkAssistantInsights] : []) + .use(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(CHAT_MARKDOWN_REHYPE_PLUGINS); +} +const assistantProcessor = assistantTextProcessor(); +const assistantBreaksProcessor = assistantTextProcessor(true); +const insightProcessor = assistantTextProcessor(false, true); +const insightBreaksProcessor = assistantTextProcessor(true, true); const userProcessor = unified() .use(remarkParse) .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) @@ -111,13 +114,19 @@ function markdownThreadFindText( cwd?: string, skills: readonly InlineSkill[] = [], lineBreaks = false, + renderInsights = false, ): string[] { const processor = userMessage ? userProcessor : lineBreaks ? assistantBreaksProcessor : assistantProcessor; - const tree = processor.runSync(processor.parse(markdown)); + const renderer = renderInsights + ? lineBreaks + ? insightBreaksProcessor + : insightProcessor + : processor; + const tree = renderer.runSync(renderer.parse(markdown), markdown); const paths = [ ...extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(markdown)), ...extractInlineCodeSpans(markdown).flatMap((span) => inlineCodeFilePathCandidate(span) ?? []), @@ -231,5 +240,6 @@ export function searchableMessageSegments( cwd, skills, shouldPreserveAssistantLineBreaks(message.text), + true, ); } From ba32aabf62e6d33b55656bf8b836d407303a70fe Mon Sep 17 00:00:00 2001 From: Shaun Hawk Date: Wed, 11 Nov 2026 05:30:30 -0500 Subject: [PATCH 16/56] fix(scheduler): show short server timezone on fixed-time labels (#23111) Co-authored-by: Shaun Hawk Co-authored-by: Cursor Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../SettingsScheduledTasksRouteScreen.tsx | 14 ++++-- .../scheduledTasks/ScheduledTaskService.ts | 5 +- apps/server/src/ws.ts | 1 + .../chat/ThreadAutomationsPanel.tsx | 15 ++++-- .../settings/ScheduledTasksSettings.tsx | 47 ++++++++----------- .../scheduledTasksSettings.logic.test.ts | 10 ++++ .../settings/scheduledTasksSettings.logic.ts | 33 +++++++++++++ packages/client-runtime/package.json | 4 ++ .../src/scheduledTaskTimeZone.test.ts | 12 +++++ .../src/scheduledTaskTimeZone.ts | 23 +++++++++ packages/contracts/src/scheduledTask.ts | 2 + 11 files changed, 128 insertions(+), 38 deletions(-) create mode 100644 packages/client-runtime/src/scheduledTaskTimeZone.test.ts create mode 100644 packages/client-runtime/src/scheduledTaskTimeZone.ts diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index ef4b696c82ec8..3f4bc4dee90e7 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -38,6 +38,7 @@ import { WEBHOOK_FILTER_OPS, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { scheduleTimeZoneLabel } from "@t3tools/client-runtime/scheduled-task-time-zone"; import { useRef, useState, type ReactNode } from "react"; import { useFocusEffect, useNavigation, usePreventRemove } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; @@ -117,7 +118,7 @@ const THREAD_MODE_LABELS: Record = { fresh_session: "One thread, fresh session", }; -function describeSchedule(task: ScheduledTask): string { +function describeSchedule(task: ScheduledTask, timeZone?: string): string { if (task.schedule.type === "webhook") return "On webhook"; if (task.schedule.type === "once") { const runAt = new Date(task.schedule.runAt); @@ -125,7 +126,8 @@ function describeSchedule(task: ScheduledTask): string { } if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs); const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day"; - return `${days} at ${formatTime(task.schedule.timeOfDay)}`; + const zone = scheduleTimeZoneLabel(timeZone, task.nextRunAt); + return `${days} at ${formatTime(task.schedule.timeOfDay)}${zone === undefined ? "" : ` ${zone}`}`; } function formatTime(value: string): string { @@ -684,6 +686,7 @@ function TaskForm({ draft.task !== null && tasks.data !== null && !tasks.data.tasks.some((task) => task.id === draft.task?.id); + const runAtZone = scheduleTimeZoneLabel(tasks.data?.timeZone); const environmentUnavailable = !availableTargets.some( (target) => target.environmentId === environmentId, ); @@ -1058,7 +1061,9 @@ function TaskForm({ <> setTimePickerOpen((open) => !open)} className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" > @@ -1070,6 +1075,7 @@ function TaskForm({ className="min-w-0 flex-1 text-right text-foreground-muted" > {formatTime(draft.schedule.timeOfDay)} + {runAtZone ? ` ${runAtZone}` : ""} - {describeSchedule(task)} + {describeSchedule(task, tasks.data?.timeZone)} {!task.enabled ? " · Paused" : task.nextRunAt diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 4953cec766210..4fd59e610f0f3 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -1237,7 +1237,10 @@ export const layer = Layer.effect( const list: ScheduledTaskService["Service"]["list"] = () => listRows().pipe( - Effect.map((tasks) => ({ tasks })), + Effect.map((tasks) => ({ + tasks, + timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), + })), Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5564b801d6b8a..3409a3c326e20 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1557,6 +1557,7 @@ export const layerWsRpc = ( currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result : { + ...result, tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task), }; const authorizeEffect = ( diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 4c3326df8c635..1bec327103211 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -16,7 +16,8 @@ import { import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { cn } from "../../lib/utils"; -import { ScheduledRelativeLabel, scheduleLabel } from "../settings/ScheduledTasksSettings"; +import { ScheduledRelativeLabel } from "../settings/ScheduledTasksSettings"; +import { scheduleLabel } from "../settings/scheduledTasksSettings.logic"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -37,10 +38,10 @@ const STATUS_DOT_CLASS: Record = { failed: "bg-destructive", }; -function taskScheduleLine(task: ScheduledTask) { +function taskScheduleLine(task: ScheduledTask, timeZone: string | undefined) { return ( <> - {scheduleLabel(task.schedule)} + {scheduleLabel(task.schedule, timeZone, task.nextRunAt)} {task.blockedByPolicy !== undefined ? ( ` · ${task.blockedByPolicy}` ) : task.enabled && task.nextRunAt !== null ? ( @@ -179,11 +180,15 @@ export function ThreadAutomationsPanel(props: { {task.title} -

{taskScheduleLine(task)}

+

+ {taskScheduleLine(task, tasksQuery.data?.timeZone)} +

{task.title}

-

{taskScheduleLine(task)}

+

+ {taskScheduleLine(task, tasksQuery.data?.timeZone)} +

diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index ff024e5ba2ed2..848b88130f48a 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -21,7 +21,6 @@ import type { ProjectId, ScheduledTask, ScheduledTaskId, - ScheduledTaskSchedule, ScheduledTaskThreadMode, ScheduledTaskUpsertInput, ScheduledTaskWebhookDeliverySummary, @@ -35,6 +34,7 @@ import { WEBHOOK_FILTER_OPS, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { scheduleTimeZoneLabel } from "@t3tools/client-runtime/scheduled-task-time-zone"; import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES, MAX_WEBHOOK_FILTERS, @@ -104,7 +104,9 @@ import { defaultScheduledTaskProjectId, matchesScheduledTaskScope, scheduleFromDraft, + scheduleLabel, scheduledTaskDefaultModel, + WEEKDAY_LABELS, taskToDraft, unbindDraftFromThread, type DraftState, @@ -151,7 +153,6 @@ import { } from "./settingsLayout"; const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; -const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; const WEEKDAY_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; // Thread ids are trimmed, so the leading space keeps this from matching one. @@ -322,30 +323,6 @@ function splitModelKey(value: string): ModelSelection | null { }; } -const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", -}); - -export function scheduleLabel(schedule: ScheduledTaskSchedule): string { - if (schedule.type === "webhook") return "On webhook"; - if (schedule.type === "once") return `Once, ${ONCE_FORMATTER.format(new Date(schedule.runAt))}`; - if (schedule.type === "interval") { - const minutes = schedule.everyMs / 60_000; - return Number.isInteger(minutes) - ? `Every ${minutes} min` - : `Every ${Math.round(schedule.everyMs / 1000)} sec`; - } - const weekdays = schedule.weekdays ?? []; - const days = - weekdays.length === 0 - ? "Daily" - : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) - ? "Weekdays" - : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); - return `${days} at ${schedule.timeOfDay}`; -} - /** formatRelativeTime handles only the past; upcoming runs need future labels. */ function relativeLabel(value: string | null, nowMs: number): string { if (!value) return "Not scheduled"; @@ -552,6 +529,7 @@ function ScheduledTaskEnvironmentList({ return { environment, connected: index >= 0, + timeZone: data?.timeZone, tasks: data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ), @@ -586,7 +564,7 @@ function ScheduledTaskEnvironmentList({ return ( - {populated.map(({ environment, tasks }) => { + {populated.map(({ environment, tasks, timeZone }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); return (
@@ -610,6 +588,7 @@ function ScheduledTaskEnvironmentList({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> ))} @@ -734,6 +713,7 @@ function ScheduledTaskEnvironmentSection({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={tasksQuery.data?.timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> )) @@ -747,10 +727,12 @@ function ScheduledTaskEnvironmentSection({ function ScheduledTaskRow({ environmentId, task, + timeZone, onEdit, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask; + readonly timeZone: string | undefined; readonly onEdit: () => void; }) { const canOperate = useAtomValue( @@ -795,7 +777,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule)} ·{" "} + {scheduleLabel(task.schedule, timeZone, task.nextRunAt)} ·{" "} {task.blockedByPolicy !== undefined ? ( task.blockedByPolicy ) : !task.enabled ? ( @@ -1416,6 +1398,7 @@ function ScheduledTaskEditorForm({ draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); + const runAtZone = scheduleTimeZoneLabel(tasksQuery.data?.timeZone); const liveTask = tasksQuery.data ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) : task; @@ -2063,6 +2046,9 @@ function ScheduledTaskEditorForm({ ({ ...current, timeOfDay: event.target.value })) } /> + {runAtZone === undefined ? null : ( + + {runAtZone} + + )} on
{ expect(scheduleFromDraft({ ...draft, replayKey: "literal" })).toBeNull(); expect(scheduleFromDraft({ ...draft, coalesceKey: "{{body.bad" })).toBeNull(); }); + +it("appends the server zone only to fixed-time schedule labels", () => { + expect(scheduleLabel({ type: "fixed_time", timeOfDay: "16:31" }, "Etc/UTC")).toBe( + "Daily at 16:31 UTC", + ); + expect(scheduleLabel({ type: "fixed_time", timeOfDay: "09:00" })).toBe("Daily at 09:00"); + expect(scheduleLabel({ type: "interval", everyMs: 60_000 }, "Etc/UTC")).toBe("Every 1 min"); + expect(scheduleLabel({ type: "webhook", signature: null }, "Etc/UTC")).toBe("On webhook"); +}); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 26d83da69f428..1095db83469ba 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -5,6 +5,7 @@ import { type ProjectId, ScheduledTaskId, type ScheduledTask, + type ScheduledTaskSchedule, type ScheduledTaskThreadMode, type ScheduledTaskUpsertSchedule, scheduledTaskThreadMode, @@ -22,6 +23,7 @@ import { webhookFilterDraftsChanged, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { scheduleTimeZoneLabel } from "@t3tools/client-runtime/scheduled-task-time-zone"; import { resolveProjectSettings, @@ -31,6 +33,37 @@ import type { ProviderInstanceEntry } from "../../providerInstances"; import type { ResolvedSettingsScope } from "./settingsScope"; +export const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; + +const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +export function scheduleLabel( + schedule: ScheduledTaskSchedule, + timeZone?: string, + runAt?: string | null, +): string { + if (schedule.type === "webhook") return "On webhook"; + if (schedule.type === "once") return `Once, ${ONCE_FORMATTER.format(new Date(schedule.runAt))}`; + if (schedule.type === "interval") { + const minutes = schedule.everyMs / 60_000; + return Number.isInteger(minutes) + ? `Every ${minutes} min` + : `Every ${Math.round(schedule.everyMs / 1000)} sec`; + } + const weekdays = schedule.weekdays ?? []; + const days = + weekdays.length === 0 + ? "Daily" + : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) + ? "Weekdays" + : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); + const zone = scheduleTimeZoneLabel(timeZone, runAt); + return `${days} at ${schedule.timeOfDay}${zone === undefined ? "" : ` ${zone}`}`; +} + export function matchesScheduledTaskScope( scope: ResolvedSettingsScope, environmentId: EnvironmentId, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c9e1260b956f7..920fad80e4d17 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -83,6 +83,10 @@ "types": "./src/scheduledTaskWebhook.ts", "default": "./src/scheduledTaskWebhook.ts" }, + "./scheduled-task-time-zone": { + "types": "./src/scheduledTaskTimeZone.ts", + "default": "./src/scheduledTaskTimeZone.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" diff --git a/packages/client-runtime/src/scheduledTaskTimeZone.test.ts b/packages/client-runtime/src/scheduledTaskTimeZone.test.ts new file mode 100644 index 0000000000000..35ce8c4b70f7a --- /dev/null +++ b/packages/client-runtime/src/scheduledTaskTimeZone.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "vite-plus/test"; + +import { scheduleTimeZoneLabel } from "./scheduledTaskTimeZone.ts"; + +it("uses short names instead of full IANA zones", () => { + expect(scheduleTimeZoneLabel(undefined)).toBeUndefined(); + expect(scheduleTimeZoneLabel("Unsupported/Zone")).toBeUndefined(); + expect(scheduleTimeZoneLabel("Etc/UTC")).toBe("UTC"); + expect(scheduleTimeZoneLabel("America/Los_Angeles")).toMatch(/^(PT|Los Angeles Time)$/); + expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-01-15T17:00:00Z")).toBe("PST"); + expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-07-15T16:00:00Z")).toBe("PDT"); +}); diff --git a/packages/client-runtime/src/scheduledTaskTimeZone.ts b/packages/client-runtime/src/scheduledTaskTimeZone.ts new file mode 100644 index 0000000000000..9321ab11acbe8 --- /dev/null +++ b/packages/client-runtime/src/scheduledTaskTimeZone.ts @@ -0,0 +1,23 @@ +import * as DateTime from "effect/DateTime"; + +/** + * Short label for the server zone a fixed-time schedule runs in. With a run time it names + * that instant's offset (PST/PDT); without one it uses the generic name (PT). + */ +export function scheduleTimeZoneLabel( + timeZone: string | undefined, + runAt?: string | null, +): string | undefined { + if (timeZone === undefined) return undefined; + if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; + try { + return new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: runAt ? "short" : "shortGeneric", + }) + .formatToParts(runAt ? DateTime.toEpochMillis(DateTime.makeUnsafe(runAt)) : undefined) + .find((part) => part.type === "timeZoneName")?.value; + } catch { + return undefined; + } +} diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 2a77777a9c3dc..4297ad94b73e9 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -271,6 +271,8 @@ export type ScheduledTaskListInput = typeof ScheduledTaskListInput.Type; export const ScheduledTaskListResult = Schema.Struct({ tasks: ForwardCompatibleArray(ScheduledTask), + /** The server's IANA zone for fixed-time schedules. Absent from older servers. */ + timeZone: Schema.optional(TrimmedNonEmptyString), }); export type ScheduledTaskListResult = typeof ScheduledTaskListResult.Type; From 43821388a9af0bdd2e41c36af31cbc859fba5a71 Mon Sep 17 00:00:00 2001 From: Roger V <12006627+rgrvlsk@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:35:44 +0100 Subject: [PATCH 17/56] fix(web): focus the primary action when confirmation dialogs open (#18187) Co-authored-by: Roger Vilaseca Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/web/src/components/ConfirmDialogHost.tsx | 12 +- .../web/src/components/ServerUpdateAction.tsx | 2 + .../cloud/RelayClientInstallDialog.tsx | 5 +- .../settings/BrowserImportWizard.tsx | 5 +- .../components/settings/SettingsPanels.tsx | 1 + .../components/sidebar/SidebarUpdatePill.tsx | 1 + apps/web/src/components/ui/alert-dialog.tsx | 39 +++++-- .../src/components/ui/dialog.browser.test.tsx | 108 ++++++++++++++++++ apps/web/src/components/ui/dialog.tsx | 83 ++++++++++---- apps/web/src/confirmDialog.test.ts | 5 +- apps/web/src/confirmDialog.ts | 25 +++- packages/contracts/src/ipc.ts | 2 + 12 files changed, 240 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/components/ui/dialog.browser.test.tsx diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index 8992dc43d7c1c..514c719dba73d 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -9,6 +9,7 @@ import { import { AlertDialog, AlertDialogClose, + AlertDialogAction, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, @@ -59,6 +60,7 @@ export function ConfirmDialogHost() { const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message); const confirmVariant = state.status === "idle" ? "default" : state.variant; + const focusConfirm = state.status !== "idle" && state.initialFocus === "confirm"; const onCancel = () => respondToConfirmDialog(false); const onConfirm = () => respondToConfirmDialog(true); @@ -83,9 +85,13 @@ export function ConfirmDialogHost() { }>Cancel - + {focusConfirm && confirmVariant === "default" ? ( + Confirm + ) : ( + + )} diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 32a2811aca189..14e0dd3c192cb 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -202,6 +202,7 @@ export function ServerUpdatesAction({ const confirmed = (await requestConfirmDialog( `Update the T3 Code desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`, + { initialFocus: "confirm" }, )) ?? true; if (!confirmed) return; } @@ -308,6 +309,7 @@ export function ServerUpdateAction({ const confirmed = (await requestConfirmDialog( `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, + { initialFocus: "confirm" }, )) ?? true; if (!confirmed) { return; diff --git a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx index 78282f659151f..cff5a659c95a4 100644 --- a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx +++ b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx @@ -12,6 +12,7 @@ import { Button } from "../ui/button"; import { Dialog, DialogDescription, + DialogAction, DialogFooter, DialogHeader, DialogPanel, @@ -112,9 +113,9 @@ export function RelayClientInstallDialog() { > Cancel - + ) : null} diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx index b12696ce89e43..7fc81b7449253 100644 --- a/apps/web/src/components/settings/BrowserImportWizard.tsx +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -12,6 +12,7 @@ import { Dialog, DialogClose, DialogDescription, + DialogAction, DialogFooter, DialogHeader, DialogPanel, @@ -201,7 +202,7 @@ function QuitStep({ - + I’ve quit it ); @@ -575,7 +576,7 @@ function BlockedStep({ - {onRetry ? : null} + {onRetry ? Try again : null} ); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 3dc9566e8981d..dcaca88e6c7f7 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -223,6 +223,7 @@ function AboutVersionSection() { getDesktopUpdateInstallConfirmationMessage( updateState ?? { availableVersion: null, downloadedVersion: null }, ), + { initialFocus: "confirm" }, ); } catch (error) { setIsUpdateActionPending(false); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 3c18b2c7a927b..5df8007ef4677 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -308,6 +308,7 @@ function SidebarUpdateControl() { try { confirmed = await ensureLocalApi().dialogs.confirm( getDesktopUpdateInstallConfirmationMessage(state), + { initialFocus: "confirm" }, ); } catch (error) { setIsActionPending(false); diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index 6319dcfa5a052..909fe8905fc3d 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -1,4 +1,5 @@ import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import { useRender } from "@base-ui/react/use-render"; import { cn } from "~/lib/utils"; import { @@ -6,7 +7,12 @@ import { DIALOG_MOBILE_SHEET_CLASS, DIALOG_POPUP_CLASS, } from "~/components/ui/dialog-styles"; -import { ControlledOpenContext, renderRetainedPopup } from "~/components/ui/dialog"; +import { + ControlledOpenContext, + DialogAction, + renderRetainedPopup, + useDialogActionInitialFocus, +} from "~/components/ui/dialog"; function AlertDialog(props: AlertDialogPrimitive.Root.Props) { return ( @@ -51,23 +57,31 @@ function AlertDialogPopup({ bottomStickOnMobile?: boolean; portalContainer?: AlertDialogPrimitive.Portal.Props["container"]; }) { + const { popupRef, initialFocus } = useDialogActionInitialFocus(); + const popup = useRender({ + ref: popupRef, + render: ( + + ), + }); return ( - + {popup} ); @@ -146,5 +160,6 @@ export { AlertDialogTitle, AlertDialogDescription, AlertDialogClose, + DialogAction as AlertDialogAction, AlertDialogViewport, }; diff --git a/apps/web/src/components/ui/dialog.browser.test.tsx b/apps/web/src/components/ui/dialog.browser.test.tsx new file mode 100644 index 0000000000000..848d64ed0933e --- /dev/null +++ b/apps/web/src/components/ui/dialog.browser.test.tsx @@ -0,0 +1,108 @@ +import { createRef } from "react"; +import { describe, expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { requestConfirmDialog, resetConfirmDialogForTests } from "~/confirmDialog"; +import { act, render } from "../../../test/render"; +import { ConfirmDialogHost } from "../ConfirmDialogHost"; +import { Button } from "./button"; +import { + Dialog, + DialogAction, + DialogClose, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "./dialog"; + +describe("dialog initial focus", () => { + it.each([ + { label: "generic", options: undefined, focused: "Cancel", confirmed: false }, + { + label: "destructive opt-in", + options: { variant: "destructive", initialFocus: "confirm" }, + focused: "Cancel", + confirmed: false, + }, + { label: "opt-in", options: { initialFocus: "confirm" }, focused: "Confirm", confirmed: true }, + ] as const)( + "focuses $focused for a $label confirmation", + async ({ options, focused, confirmed }) => { + resetConfirmDialogForTests(); + await using _screen = await render(); + let confirmation: Promise | undefined; + await act(() => { + confirmation = requestConfirmDialog("Continue?", options); + }); + + await expect.element(page.getByRole("button", { name: focused, exact: true })).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + await expect(confirmation).resolves.toBe(confirmed); + }, + ); + + it("focuses the dialog action and keeps the caller's popup ref", async () => { + const popup = createRef(); + await using _screen = await render( + + Open retry + + Import failed + Cancel + Try again + + , + ); + await act(() => page.getByRole("button", { name: "Open retry" }).element().focus()); + await userEvent.keyboard("{Enter}"); + + await expect.element(page.getByRole("button", { name: "Try again" })).toHaveFocus(); + expect(popup.current).toBe(page.getByRole("dialog").element()); + }); + + it.each(["disabled action", "caller initialFocus"] as const)( + "falls back to Cancel for a %s", + async (reason) => { + const cancel = createRef(); + await using _screen = await render( + + + Install update? + }> + Cancel + + Install + + , + ); + + await expect.element(page.getByRole("button", { name: "Cancel", exact: true })).toHaveFocus(); + }, + ); + + it("focuses the popup instead of the action when opened by touch", async () => { + await using _screen = await render( + + Open installer + + Install relay client + Cancel + Install + + , + ); + + const trigger = page.getByRole("button", { name: "Open installer" }).element(); + await act(() => { + trigger.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }), + ); + trigger.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerType: "touch" })); + trigger.dispatchEvent( + new PointerEvent("click", { bubbles: true, pointerType: "touch", detail: 1 }), + ); + }); + + await expect.element(page.getByRole("dialog")).toHaveFocus(); + }); +}); diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 2c5bc14905bbc..de48db6325ffe 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -1,8 +1,9 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { useRender } from "@base-ui/react/use-render"; import { XIcon } from "lucide-react"; -import { type ComponentProps, createContext, type ReactNode, use, useState } from "react"; +import { type ComponentProps, createContext, type ReactNode, use, useRef, useState } from "react"; import { cn } from "~/lib/utils"; -import { Button } from "~/components/ui/button"; +import { Button, type ButtonVariant } from "~/components/ui/button"; import { DIALOG_BACKDROP_CLASS, DIALOG_MEDIA_BACKDROP_CLASS, @@ -40,6 +41,20 @@ function Dialog(props: DialogPrimitive.Root.Props) { ); } +function useDialogActionInitialFocus() { + const popupRef = useRef(null); + const initialFocus = (openType: string) => { + // Match Base UI: touch opens focus the popup to avoid opening the virtual keyboard. + if (openType === "touch") return popupRef.current; + return ( + popupRef.current?.querySelector( + '[data-slot="dialog-action"]:not(:disabled):not([aria-disabled="true"])', + ) ?? true + ); + }; + return { popupRef, initialFocus }; +} + const DialogPortal = DialogPrimitive.Portal; function DialogTrigger(props: DialogPrimitive.Trigger.Props) { @@ -93,6 +108,36 @@ function DialogPopup({ bottomStickOnMobile?: boolean; variant?: "default" | "media"; }) { + const { popupRef, initialFocus } = useDialogActionInitialFocus(); + const popup = useRender({ + ref: popupRef, + render: ( + + {children} + {showCloseButton && ( + } + > + + + )} + + ), + }); return ( {/* Media opens from inside other overlays (a composer chip, a popover), so it sits above them. */} @@ -104,34 +149,20 @@ function DialogPopup({ "z-[60] grid-rows-1 place-items-center px-4 pb-6 pt-[calc(var(--native-titlebar-height,0px)+1.5rem)] [-webkit-app-region:no-drag]", )} > - - {children} - {showCloseButton && ( - } - > - - - )} - + {popup} ); } +type DialogActionProps = Omit, "variant"> & { + variant?: Exclude; +}; + +function DialogAction(props: DialogActionProps) { + return
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index 98db15d1429c7..19c6cb75c5ac5 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -185,3 +185,49 @@ it("leaves description checkboxes read-only without the right to edit it", async true, ); }); + +it("fixes an overview thread as its opening remark, since it has no line to attach", async () => { + setup(); + const author = { login: "reviewer", name: null, avatarUrl: null }; + const note = { author, createdAt: "2026-09-01T00:00:00Z", url: null }; + const remark = { + ...note, + id: "10", + kind: "review-comment" as const, + body: "Mention the flag in the README", + path: null, + reviewState: null, + }; + const onFixFinding = vi.fn(); + const value: PullRequestDetailView = { + ...detail, + comments: [remark], + overviewThreads: [ + { + id: "overview", + path: null, + line: null, + side: "right", + isResolved: false, + isOutdated: false, + comments: [{ ...note, id: "10", body: remark.body }], + }, + ], + }; + await using screen = await renderUi( + {}} + environmentId={EnvironmentId.make("environment")} + threadRef={null} + reference={value} + detail={value} + activityPending={false} + activityError={null} + onRefresh={() => {}} + onFixFinding={onFixFinding} + />, + ); + + await userEvent.click(screen.getByRole("button", { name: "Fix in a thread" })); + expect(onFixFinding).toHaveBeenCalledWith({ kind: "comment", comment: remark }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 58be7cfb29485..a78cdfbd6f06f 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -6,13 +6,14 @@ import { sourceControlClients, } from "@t3tools/client-runtime/source-control-clients"; import { useAtomCommand } from "~/state/use-atom-command"; -import type { - EnvironmentId, - PullRequestComment, - PullRequestDetailView, - PullRequestRef, - PullRequestReviewThread, - ScopedThreadRef, +import { + type EnvironmentId, + type PullRequestComment, + type PullRequestDetailView, + type PullRequestRef, + type PullRequestConversationThread, + type ScopedThreadRef, + pullRequestConversationThreads, } from "@t3tools/contracts"; import { ArrowDownUpIcon, @@ -93,6 +94,7 @@ import { pullRequestCompletionScope, } from "./PullRequestMarkdownTextarea"; import { PullRequestReactionBar } from "./PullRequestReactions"; +import { PullRequestResolveThreadButton } from "./PullRequestResolveThreadButton"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; @@ -147,7 +149,7 @@ function CommentLocation({ thread, }: { comment: PullRequestComment; - thread: PullRequestReviewThread | undefined; + thread: PullRequestConversationThread | undefined; }) { const path = thread?.path ?? comment.path; if (!path) return null; @@ -228,9 +230,10 @@ function CommentBody({ */ function chatSubject( comment: PullRequestComment, - thread: PullRequestReviewThread | undefined, + thread: PullRequestConversationThread | undefined, ): PullRequestChatSubject | null { - if (thread !== undefined) { + // An overview thread has no line to attach, so it goes as its opening remark, as fixing it does. + if (thread !== undefined && thread.path !== null) { return thread.comments.some((entry) => visibleBody(entry.body) !== null) ? { kind: "thread", thread } : null; @@ -256,6 +259,7 @@ function CollapsedComment({ label, body, reactionBar, + resolveButton, detail, thread, replyCount = 0, @@ -268,8 +272,9 @@ function CollapsedComment({ label: string; body: string | null; reactionBar: ReactNode; + resolveButton: ReactNode; detail: PullRequestDetailView; - thread: PullRequestReviewThread | undefined; + thread: PullRequestConversationThread | undefined; replyCount?: number; replies?: ReactNode; moreReplies?: ReactNode; @@ -298,6 +303,8 @@ function CollapsedComment({ className={cn("size-3.5 transition-transform", open && "rotate-180")} /> + {/* Collapsed, the status label already says "Resolved". */} + {open ? resolveButton : null} {onAddToChat ? : null} {reactionBar}
@@ -481,7 +488,10 @@ export function PullRequestSummaryTab({ DEFAULT_COMMENT_FILTER, CommentFilterSchema, ); - const items = pullRequestConversationItems(detail.comments, detail.reviewThreads); + const items = pullRequestConversationItems( + detail.comments, + pullRequestConversationThreads(detail), + ); const filteredItems = filterPullRequestConversationItems(items, commentFilter); const commentCount = items.reduce( (count, item) => count + pullRequestConversationItemComments(item).length, @@ -696,9 +706,20 @@ export function PullRequestSummaryTab({ /> ); + const resolveButtonFor = (thread: PullRequestConversationThread | undefined) => + thread === undefined ? null : ( + + ); + const renderCommentCard = ( comment: PullRequestComment, - thread: PullRequestReviewThread | undefined, + thread: PullRequestConversationThread | undefined, { reply = false }: { reply?: boolean } = {}, ) => { const body = visibleBody(comment.body); @@ -708,7 +729,7 @@ export function PullRequestSummaryTab({ (comment.kind !== "review" && comment.kind !== "review-comment") || outcome === "approved" ? null - : thread === undefined + : thread === undefined || thread.path === null ? body === null ? null : { kind: "comment", comment } @@ -747,6 +768,7 @@ export function PullRequestSummaryTab({ ) : null} + {resolveButtonFor(thread)} {onAddToChat && chat !== null ? ( onAddToChat(chat)} /> ) : null} @@ -791,6 +813,7 @@ export function PullRequestSummaryTab({ moreReplies={moreReplies} onAddToChat={onAddToChat && chat !== null ? () => onAddToChat(chat) : undefined} reactionBar={reactionBarFor(comment)} + resolveButton={resolveButtonFor(thread)} /> ); } diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index df0e9c260d88b..abefb7e1d7e87 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -56,6 +56,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestMoreReplies } from "./PullRequestMoreReplies"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; +import { PullRequestResolveThreadButton } from "./PullRequestResolveThreadButton"; import { PullRequestActorAvatar, PullRequestDiffStat, @@ -443,10 +444,12 @@ function ReviewThreadEvent({ cwd, onOpen, reactions, + detail, }: { revealAnchor?: string | null; event: PullRequestTimelineEvent; thread: PullRequestTimelineThread; + detail: PullRequestDetailView; editable: ReadonlyMap; cwd: string; onOpen: (url: string) => void; @@ -471,40 +474,60 @@ function ReviewThreadEvent({ fallback={} muted={!expanded} /> - + {expanded ? ( + ) : null} - {dismissed ? Review dismissed : null} - {thread.isOutdated ? Outdated : null} - {expanded ? null : ( - - · {event.actor?.login ?? "ghost"} - {replyCount > 0 ? ( - <> - {" "} - · {replyCount.toLocaleString()} {replyCount === 1 ? "reply" : "replies"} - - ) : null} - - )} - - +
{expanded ? ( <> , + Pick< + PullRequestConversationThread, + "id" | "path" | "line" | "isResolved" | "isOutdated" | "canResolve" + >, PullRequestReplyAvailability { readonly replies: ReadonlyArray; } @@ -557,12 +561,12 @@ export type PullRequestConversationItem = readonly createdAt: string; readonly root: PullRequestComment; readonly replies: ReadonlyArray; - readonly thread: PullRequestReviewThread; + readonly thread: PullRequestConversationThread; } & PullRequestReplyAvailability); export function pullRequestConversationItems( comments: ReadonlyArray, - reviewThreads: ReadonlyArray, + reviewThreads: ReadonlyArray, ): ReadonlyArray { const byId = new Map(comments.map((comment) => [comment.id, comment])); const claimed = new Set(); @@ -623,7 +627,7 @@ export function pullRequestConversationItemComments( export function isPullRequestCommentFinished( comment: PullRequestComment, - thread?: PullRequestReviewThread, + thread?: PullRequestConversationThread, ): boolean { return ( thread?.isResolved === true || pullRequestReviewOutcome(comment.reviewState) === "dismissed" @@ -780,7 +784,7 @@ export function buildPullRequestTimeline( PullRequestDetailView, "provider" | "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt" > & - Partial>, + Partial>, ): ReadonlyArray { const term = sourceControlClients.get(detail.provider).changeRequest.singular; return [ @@ -816,11 +820,16 @@ export function buildPullRequestTimeline( reviewState: null, reactions: [], })), - ...pullRequestConversationItems(detail.comments, detail.reviewThreads ?? []).map((item) => { + ...pullRequestConversationItems(detail.comments, [ + ...(detail.reviewThreads ?? []), + ...(detail.overviewThreads ?? []), + ]).map((item) => { if (item.kind === "comment") return commentTimelineEvent(item.comment); return { ...commentTimelineEvent(item.root), thread: { + id: item.thread.id, + ...(item.thread.canResolve === undefined ? {} : { canResolve: item.thread.canResolve }), path: item.thread.path, line: item.thread.line, isResolved: item.thread.isResolved, diff --git a/apps/web/src/components/pullRequest/usePullRequestDetail.ts b/apps/web/src/components/pullRequest/usePullRequestDetail.ts index 7d6ab0c7a6154..fb7515c646009 100644 --- a/apps/web/src/components/pullRequest/usePullRequestDetail.ts +++ b/apps/web/src/components/pullRequest/usePullRequestDetail.ts @@ -182,6 +182,9 @@ export function usePullRequestDetail( ), commentsTruncated: activity?.commentsTruncated ?? false, reviewThreads: activity?.reviewThreads ?? [], + ...(activity?.overviewThreads === undefined + ? {} + : { overviewThreads: activity.overviewThreads }), commits: activity?.commits ?? [], reactions: activity?.reactions ?? [], }; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 53043895b8477..014b26272af73 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -196,6 +196,16 @@ export const PullRequestReviewThread = Schema.Struct({ }); export type PullRequestReviewThread = typeof PullRequestReviewThread.Type; +/** A thread on the change request as a whole, such as a GitLab overview thread. */ +export const PullRequestOverviewThread = Schema.Struct({ + ...PullRequestReviewThread.fields, + path: Schema.Null, + line: Schema.Null, +}); +export type PullRequestOverviewThread = typeof PullRequestOverviewThread.Type; + +export type PullRequestConversationThread = PullRequestReviewThread | PullRequestOverviewThread; + export const PullRequestReviewerKind = Schema.Literals(["user", "team"]); export type PullRequestReviewerKind = typeof PullRequestReviewerKind.Type; @@ -667,11 +677,20 @@ export const PullRequestActivity = Schema.Struct({ commentsTruncated: Schema.Boolean, reviewThreadsTruncated: Schema.optional(Schema.Boolean), reviewThreads: Schema.Array(PullRequestReviewThread), + // Apart from reviewThreads so clients that require a thread path still decode the activity. + overviewThreads: Schema.optional(Schema.Array(PullRequestOverviewThread)), commits: Schema.Array(PullRequestCommit), reactions: Schema.optional(Schema.Array(PullRequestReaction)), }); export type PullRequestActivity = typeof PullRequestActivity.Type; +export const pullRequestConversationThreads = ( + activity: Pick, +): ReadonlyArray => + activity.overviewThreads === undefined || activity.overviewThreads.length === 0 + ? activity.reviewThreads + : [...activity.reviewThreads, ...activity.overviewThreads]; + export const PullRequestDetailView = Schema.Struct({ ...PullRequestDetail.fields, ...PullRequestActivity.fields, diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index be60f64c9c9a7..d5aab49d2cc0a 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -28,6 +28,7 @@ import type { PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewDecision, + PullRequestOverviewThread, PullRequestReviewThread, PullRequestThreadCommentsResult, PullRequestReviewVerdict, @@ -221,6 +222,7 @@ export interface ProviderChangeRequestActivity { readonly commentsTruncated: boolean; readonly reviewThreadsTruncated?: boolean; readonly reviewThreads: ReadonlyArray; + readonly overviewThreads?: ReadonlyArray; readonly commits: ReadonlyArray; readonly reactions?: ReadonlyArray; } diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts index 550fb3cac9d4e..4cdc6d1f15b6b 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts @@ -1417,7 +1417,7 @@ layer("GitLabPullRequestCli.layer", (it) => { ); it.effect( - "reads a positioned discussion as a thread and an open overview discussion as a finding", + "reads a positioned discussion at its line and an overview discussion as a thread and a finding", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -1534,9 +1534,14 @@ layer("GitLabPullRequestCli.layer", (it) => { expect(overviewFindingIds).toEqual(["4"]); expect(threads.map(({ id, path, line, side }) => ({ id, path, line, side }))).toEqual([ { id: "abc123", path: "src/a.ts", line: 12, side: "right" }, + { id: "ghi789", path: null, line: null, side: "right" }, + { id: "jkl012", path: null, line: null, side: "right" }, { id: "file789", path: "src/b.ts", line: null, side: "right" }, { id: "img012", path: "logo.png", line: null, side: "right" }, ]); + expect(threads[1]).toMatchObject({ isResolved: false }); + expect(threads[1]?.comments.map(({ id }) => id)).toEqual(["4", "5"]); + expect(threads[2]).toMatchObject({ isResolved: true }); expect([...diffRefsByThreadId]).toEqual([ ["abc123", { baseSha: "base", startSha: "start", headSha: "head" }], ]); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts index ebd506df2a653..1ee2d355d3fa3 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -29,7 +29,7 @@ import type { PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewPosition, - PullRequestReviewThread, + PullRequestConversationThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, } from "@t3tools/contracts"; @@ -345,7 +345,7 @@ const isVcsProcessExitError = Schema.is(VcsProcessExitError); const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; export interface GitLabDiscussionThreads { - readonly threads: ReadonlyArray; + readonly threads: ReadonlyArray; readonly diffRefsByThreadId: ReadonlyMap; readonly overviewFindingIds: ReadonlyArray; readonly truncated: boolean; @@ -1310,7 +1310,7 @@ export const make = Effect.gen(function* () { readonly repository: string; readonly number: number; readonly page: number; - readonly collected: ReadonlyArray; + readonly collected: ReadonlyArray; readonly collectedDiffRefs: ReadonlyMap; readonly collectedFindingIds: ReadonlyArray; }): Effect.Effect => diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts index 5beb59cb3a8cb..c8f684ff8999c 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts @@ -605,35 +605,51 @@ describe("optional activity reads", () => { }), ); - it.effect("turns the first note of an open overview discussion into a review finding", () => - Effect.gen(function* () { - const finding = { ...comment, id: "10", body: "Mention the flag in the README" }; - const reply = { ...comment, id: "11", body: "Will do" }; - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ - ...reads, - listNotes: () => - Effect.succeed({ comments: [comment, finding, reply], truncated: false }), - listDiscussions: () => - Effect.succeed({ - threads: [], - diffRefsByThreadId: new Map(), - overviewFindingIds: ["10"], - truncated: false, - }), - }), - ), - ); + it.effect( + "turns an open overview discussion into a finding and a thread apart from file threads", + () => + Effect.gen(function* () { + const finding = { ...comment, id: "10", body: "Mention the flag in the README" }; + const reply = { ...comment, id: "11", body: "Will do" }; + const thread = { + side: "right", + isResolved: false, + isOutdated: false, + comments: [], + } as const; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + ...reads, + listNotes: () => + Effect.succeed({ comments: [comment, finding, reply], truncated: false }), + listDiscussions: () => + Effect.succeed({ + threads: [ + { ...thread, id: "overview", path: null, line: null }, + { ...thread, id: "file", path: "src/a.ts", line: 3 }, + ], + diffRefsByThreadId: new Map(), + overviewFindingIds: ["10"], + truncated: false, + }), + }), + ), + ); - const { comments } = yield* provider.getChangeRequestActivity(reference); + const { comments, reviewThreads, overviewThreads } = + yield* provider.getChangeRequestActivity(reference); - expect(comments.map((item) => [item.id, item.kind])).toEqual([ - ["42", "issue-comment"], - ["10", "review-comment"], - ["11", "issue-comment"], - ]); - }), + // Clients that require a thread path must never receive one without it. + expect(reviewThreads.map(({ id }) => id)).toEqual(["file"]); + expect(overviewThreads?.map(({ id }) => id)).toEqual(["overview"]); + + expect(comments.map((item) => [item.id, item.kind])).toEqual([ + ["42", "issue-comment"], + ["10", "review-comment"], + ["11", "issue-comment"], + ]); + }), ); it.effect("dates edited comments in the conversation and in threads", () => @@ -785,6 +801,7 @@ describe("optional activity reads", () => { commentCount: 1, commentsTruncated: true, reviewThreads: [], + overviewThreads: [], reactions: [], commits: [commit], }); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 7334d93a67385..df9210fb24124 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -8,6 +8,7 @@ import type { PullRequestActor, PullRequestCapabilities, PullRequestReaction, + PullRequestThreadComment, PullRequestViewerPermissions, } from "@t3tools/contracts"; @@ -423,6 +424,13 @@ export const make = Effect.gen(function* () { author !== null && botLogins.has(author.login.toLowerCase()) ? { ...author, isBot: true } : author; + const withNoteDetails = (comment: PullRequestThreadComment) => ({ + ...comment, + ...edited(comment.id), + author: withBotFlag(comment.author), + url: noteUrl(comment.id), + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + }); return { reactions: awards.reactions, comments: notes.comments.map((comment) => ({ @@ -435,20 +443,25 @@ export const make = Effect.gen(function* () { })), commentCount: notes.comments.length, commentsTruncated: notes.truncated || discussions.truncated, - reviewThreads: discussions.threads.map((thread) => ({ - ...thread, - isOutdated: isOutdated( - discussions.diffRefsByThreadId.get(thread.id), - detail?.diffRefs, - ), - comments: thread.comments.map((comment) => ({ - ...comment, - ...edited(comment.id), - author: withBotFlag(comment.author), - url: noteUrl(comment.id), - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], - })), - })), + reviewThreads: discussions.threads.flatMap((thread) => + thread.path === null + ? [] + : [ + { + ...thread, + isOutdated: isOutdated( + discussions.diffRefsByThreadId.get(thread.id), + detail?.diffRefs, + ), + comments: thread.comments.map(withNoteDetails), + }, + ], + ), + overviewThreads: discussions.threads.flatMap((thread) => + thread.path === null + ? [{ ...thread, comments: thread.comments.map(withNoteDetails) }] + : [], + ), commits, }; }, diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index 2b9d2d22f07a3..44b87436d63bd 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -18,7 +18,7 @@ import type { PullRequestReactionContent, PullRequestReferenceCandidate, PullRequestReviewDecision, - PullRequestReviewThread, + PullRequestConversationThread, PullRequestReviewerCandidate, PullRequestState, } from "@t3tools/contracts"; @@ -789,7 +789,7 @@ export interface GitLabDiffRefs { } export interface GitLabDiscussions { - readonly threads: ReadonlyArray; + readonly threads: ReadonlyArray; /** GitLab moves a thread onto each new diff that still has its line; this is the last one. */ readonly diffRefsByThreadId: ReadonlyMap; /** First notes of unresolved discussions on the MR itself rather than a diff line. */ @@ -808,7 +808,7 @@ export function decodeDiscussionsJson( if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } - const threads: PullRequestReviewThread[] = []; + const threads: PullRequestConversationThread[] = []; const diffRefsByThreadId = new Map(); const overviewFindingIds: string[] = []; for (const entry of decoded.success) { @@ -816,11 +816,30 @@ export function decodeDiscussionsJson( if (!Exit.isSuccess(discussion)) continue; const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); const root = notes[0]; - const position = root?.position; - if (root !== undefined && !position && root.resolvable === true && root.resolved !== true) { - overviewFindingIds.push(String(root.id)); + if (root === undefined) continue; + const comments = notes.map((note) => ({ + id: String(note.id), + author: toActor(note.author), + body: note.body ?? "", + createdAt: note.created_at, + url: null, + })); + const position = root.position; + if (!position) { + // A thread started on the overview: resolvable, unlike a single comment. + if (root.resolvable !== true) continue; + if (root.resolved !== true) overviewFindingIds.push(String(root.id)); + threads.push({ + id: discussion.value.id, + path: null, + line: null, + side: "right", + isResolved: root.resolved === true, + isOutdated: false, + comments, + }); + continue; } - if (root === undefined || !position) continue; let side: "left" | "right"; let path: string | null; let line: number | null | undefined; @@ -850,13 +869,7 @@ export function decodeDiscussionsJson( side, isResolved: root.resolved === true, isOutdated: false, - comments: notes.map((note) => ({ - id: String(note.id), - author: toActor(note.author), - body: note.body ?? "", - createdAt: note.created_at, - url: null, - })), + comments, }); } return Result.succeed({ From 27188274e7513babfa5ee1112a5d3032ff299584 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:21:45 -0800 Subject: [PATCH 39/56] fix(web): pull request search keeps partial-word matches after GitHub answers (#26992) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestsPage.tsx | 2 + .../pullRequest/pullRequestList.logic.test.ts | 38 +++++++++++++++++++ .../pullRequestListGroups.logic.ts | 27 ++++++++++--- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 0a020466a83a8..9b1ad1a59950e 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -712,6 +712,8 @@ export function PullRequestsPage() { heldPullRequestsBySurface, } = pullRequestGroups({ known: ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []), + // The baseline was read without typed qualifiers, and rows cannot be checked for `checks:`. + baseline: typedParsed.filters.checks === undefined ? baselineQuery.data?.entries : undefined, providers: baselineQuery.data?.providers ?? listData?.providers ?? [], viewers, involvement: search.involvement, diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index aae1914f63491..6a38bddfc8d68 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -38,6 +38,7 @@ import { reusePullRequestEntries, settlePullRequestOverrides, } from "./pullRequestList.logic"; +import { pullRequestGroups } from "./pullRequestListGroups.logic"; import { readPullRequestListPreferences, writePullRequestListPreferences, @@ -177,6 +178,43 @@ describe("ranking what a search found", () => { rankPullRequestMatches([mentioned, titled], "welcome wizard").map((entry) => entry.number), ).toEqual([1, 2]); }); + + it("keeps loaded partial-word matches the host's whole-word search missed, after its answer", () => { + const bilal = { login: "bilal", name: null, avatarUrl: null }; + const hosted = row({ number: 1, title: "Notifications match the body text", author: bilal }); + const toast = row({ number: 2, title: "Threads show a completed toast", author: bilal }); + const toaster = row({ number: 3, title: "Toaster fixtures cover retries", author: bilal }); + const theirs = row({ number: 4, title: "Toasted sidebar" }); + const unrelated = row({ number: 5, title: "Cursor no longer adds attribution", author: bilal }); + + const { entries } = pullRequestGroups({ + known: [hosted, toast], + baseline: [theirs, unrelated, toaster, toast], + providers: [ + { + host: "github.com", + kind: SourceControlProviderKind.make("github"), + searchesOnHost: true, + projectCount: 1, + configured: true, + detail: null, + }, + ], + viewers: VIEWERS, + involvement: "all", + localFilters: { author: "bilal" }, + hasLocalFilters: true, + text: "toa", + answeredLocally: true, + baselineEmpty: false, + held: undefined, + authored: undefined, + reviewing: undefined, + partitionsWanted: false, + }); + + expect(entries.map((entry) => entry.number)).toEqual([1, 2, 3]); + }); }); describe("default merge-readiness ranking", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts index 9a3bda738a287..31f02978c918d 100644 --- a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts @@ -15,6 +15,7 @@ import { partitionPullRequestsWithPriority, pullRequestEntryKey, pullRequestEntryViewer, + rankPullRequestMatches, type EnvironmentPullRequestEntry, type PullRequestPartitionsSnapshot, } from "./pullRequestList.logic"; @@ -25,6 +26,7 @@ export const pullRequestListEntryId = (target: Parameters; + /** The text-free rows already loaded, or undefined when the typed filters cannot vet them. */ + baseline: ReadonlyArray | undefined; providers: PullRequestListResult["providers"]; viewers: PullRequestListResult["viewers"]; involvement: PullRequestInvolvement; @@ -56,22 +60,33 @@ export function pullRequestGroups({ providers.flatMap((provider) => (provider.searchesOnHost ? [provider.host] : [])), ); - const entries = (() => { - const involvementEntries = filterPullRequestsByInvolvement(known, viewers, involvement); - // Host text searches cover fields absent from rows, so do not narrow their matches locally. - // Filter text locally while results are pending or when the host has no text search. - const narrowedEntries = hasLocalFilters + const narrowRows = (rows: ReadonlyArray) => { + const involvementEntries = filterPullRequestsByInvolvement(rows, viewers, involvement); + return hasLocalFilters ? involvementEntries.filter((entry) => matchesPullRequestFilters(entry, localFilters, pullRequestEntryViewer(entry, viewers)), ) : involvementEntries; + }; + + const entries = (() => { + const narrowedEntries = narrowRows(known); if (text.length === 0) { return narrowedEntries.toSorted(comparePullRequestsByRecency); } - return narrowedEntries.filter( + // Host text searches cover fields absent from rows, so do not narrow their matches locally. + // Filter text locally while results are pending or when the host has no text search. + const matched = narrowedEntries.filter( (entry) => (answeredLocally && searchingHosts.has(entry.host)) || matchesPullRequestQuery(entry, text), ); + // Host search matches whole words, so it can miss loaded rows a partial word matches here. + // Those follow the host's ranked answer rather than reshuffling it. + const listed = new Set(matched.map(pullRequestEntryKey)); + const missed = narrowRows(baseline ?? []).filter( + (entry) => !listed.has(pullRequestEntryKey(entry)) && matchesPullRequestQuery(entry, text), + ); + return missed.length === 0 ? matched : [...matched, ...rankPullRequestMatches(missed, text)]; })(); const listedPullRequestTabStatuses: Record = Object.fromEntries( From 835e72683ba6d7eb5bac3fef7db0990438ae1f37 Mon Sep 17 00:00:00 2001 From: Juan Suaya <142438538+juansuaya17@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:27:05 -0300 Subject: [PATCH 40/56] fix(server): Claude subagents no longer show the parent's effort (#17042) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../Adapters/ClaudeAdapterV2.test.ts | 12 ++++++------ .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 9 ++------- .../OrchestratorReplayRecovery.integration.test.ts | 1 - 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4550692f3e18a..6b3ffd78063fa 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -15819,11 +15819,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("gives a nested subagent its own effort, else the one its owner runs at", () => + it.effect("records only explicit effort for nested subagents", () => Effect.scoped( Effect.gen(function* () { const OUTER_TOOL_USE_ID = "toolu-effort-outer"; - const INHERITING_TOOL_USE_ID = "toolu-effort-nested-inheriting"; + const UNSPECIFIED_TOOL_USE_ID = "toolu-effort-nested-unspecified"; const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding"; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -15889,14 +15889,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { type: "message", role: "assistant", content: [ - agentCall(INHERITING_TOOL_USE_ID), + agentCall(UNSPECIFIED_TOOL_USE_ID), agentCall(OVERRIDING_TOOL_USE_ID, "max"), ], }, uuid: "00000000-0000-4000-8000-000000000223", session_id: WAKE_NATIVE_SESSION, }), - taskStarted(INHERITING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), + taskStarted(UNSPECIFIED_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"), makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000226", @@ -15921,7 +15921,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.deepEqual(Object.fromEntries(efforts), { [`task-${OUTER_TOOL_USE_ID}`]: "low", - [`task-${INHERITING_TOOL_USE_ID}`]: "low", + [`task-${UNSPECIFIED_TOOL_USE_ID}`]: undefined, [`task-${OVERRIDING_TOOL_USE_ID}`]: "max", }); const childEfforts = harness.events.flatMap((event) => @@ -15929,7 +15929,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ? [getModelSelectionStringOptionValue(event.appThread.modelSelection, "effort")] : [], ); - assert.deepEqual(childEfforts, ["low", "low", "max"]); + assert.deepEqual(childEfforts, ["low", undefined, "max"]); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 4a252d6092edb..08e7a1f8882ff 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -6886,13 +6886,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( workflow === undefined ? undefined : withWorkflowMemberThreadIds(workflow, childThreadId); - const effort = - existingSubagent === undefined - ? (input.effort ?? - input.owner?.effort ?? - compileClaudeModelSelection(input.context.input.modelSelection, modelCatalog) - .effort) - : existingSubagent.effort; + // Unstated effort stays unknown: a custom agent definition may override the owner's. + const effort = existingSubagent === undefined ? input.effort : existingSubagent.effort; const model = input.model ?? priorTask?.model ?? null; const task = { ...(priorTask ?? { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index 6521447763951..233139235a705 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -463,7 +463,6 @@ describe("orchestrator replay recovery", () => { assert.deepEqual(child?.thread.modelSelection, { instanceId: CLAUDE_MODEL_SELECTION.instanceId, model: "claude-sonnet-5", - options: [{ id: "effort", value: "high" }], }); const conversation = (child?.turnItems ?? []) From d806251188fed764490359bd22bd2d0111a8681f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:32:26 -0800 Subject: [PATCH 41/56] fix(server): runs cancelled by a server restart say why in the thread (#27192) Co-authored-by: Claude Opus 5.5 --- .../ProviderRuntimeRecoveryService.test.ts | 1 + .../ProviderRuntimeRecoveryService.ts | 30 +++++++++++++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 20 ++++++++++--- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 642a642a80df1..e628fc5bcc4bd 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -793,6 +793,7 @@ it.effect( ["turn-item.updated", "cancelled"], ["turn-item.updated", "cancelled"], ["turn-item.updated", "cancelled"], + ["turn-item.updated", "completed"], ["provider-thread.updated", "idle"], ["provider-session.updated", "stopped"], ], diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 89e43e5b1ede4..46b739bec5732 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -634,6 +634,36 @@ export const make = Effect.gen(function* () { payload: cancelledItem(item, now), }); } + // Queued runs and unsent resumes leave no visible turn behind, so there is nothing to explain. + if (run.status === "queued" || unsentResumeRunIds.includes(run.id)) continue; + events.push({ + id: yield* allocateEventId(), + type: "turn-item.updated", + threadId: projection.thread.id, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + id: ids.derive.runSignalTurnItem({ runId: run.id, signal: "server-recovery" }), + threadId: projection.thread.id, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: run.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + // EventSink allocates the real position, after the run's existing items. + ordinal: 0, + status: "completed", + title: trigger === "startup" ? "Server restarted" : "Server shut down", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "system_notice", + message: detail, + }, + }); } // Blocking waits die with their turn; let child results wake the parent continuation as async tasks. const cutRunIds = new Set(runs.map((run) => run.id)); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a31a96cf5b463..6d6ccc63909b2 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1591,10 +1591,16 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { assert.equal(native.runtimeRequests[0]?.responseCapability.type, "not_resumable"); yield* recovery.reconcile("shutdown"); yield* recovery.reconcile("startup"); - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).runtimeRequests, - recovered.runtimeRequests, - ); + const rerecovered = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(rerecovered.runtimeRequests, recovered.runtimeRequests); + const runItems = rerecovered.turnItems.filter((item) => item.runId === runId); + const notices = runItems.filter((item) => item.type === "system_notice"); + assert.lengthOf(notices, scope === "run" ? 1 : 0); + if (scope === "run") { + assert.equal(notices[0]?.title, "Server restarted"); + assert.equal(notices[0]?.nodeId, recovered.runs[0]?.rootNodeId); + assert.equal(notices[0]?.ordinal, Math.max(...runItems.map((item) => item.ordinal))); + } yield* orchestrator.dispatch({ type: "runtime-request.respond", commandId: CommandId.make(`recovery-answer-${trigger}-${scope}`), @@ -9416,6 +9422,12 @@ it.layer(layerTest)("usage-limit recovery", (it) => { ), [run.id, resumed.runs.at(-1)!.id], ); + assert.deepEqual( + resumed.turnItems.flatMap((item) => + item.type === "system_notice" && item.title === "Server restarted" ? [item.runId] : [], + ), + scenario === "resume" ? [] : [continuation.id], + ); assert.deepEqual( resumed.runs.map((row) => row.status), scenario === "resume" ? ["failed", "cancelled", "starting"] : ["failed", "cancelled"], From a74ee4cc7c0ee9729f58dfff6cc2a118692cf8ea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:37:45 -0800 Subject: [PATCH 42/56] fix(client-runtime): server updates stop waiting 32 minutes when the host returns without this update's outcome (#27275) Co-authored-by: Claude Opus 5.5 --- packages/client-runtime/src/rpc/client.ts | 31 +- .../client-runtime/src/state/server.test.ts | 310 ++++++++++++++++-- packages/client-runtime/src/state/server.ts | 76 ++++- 3 files changed, 378 insertions(+), 39 deletions(-) diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 0d8c9e1351d0d..dd435d820d641 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -136,7 +136,7 @@ export type EnvironmentRpcStreamFailure = ? E : never; -const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { +export const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; return yield* SubscriptionRef.get(supervisor.session).pipe( Effect.flatMap( @@ -194,7 +194,7 @@ const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( yield* guard.authorize(supervisor.target.environmentId, method, input); }); -const requestInSession = Effect.fn("EnvironmentRpc.request")(function* < +export const requestInSession = Effect.fn("EnvironmentRpc.request")(function* < TTag extends EnvironmentUnaryRpcTag, >(session: RpcSession, tag: TTag, input: EnvironmentRpcInput) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; @@ -234,16 +234,23 @@ export function runStreamGuarded( EnvironmentSupervisor.EnvironmentSupervisor > { return Stream.unwrap( - authorizeRequest(tag, input).pipe( - Effect.andThen(currentSession()), - Effect.map((session) => { - const method = session.client[tag] as ( - input: EnvironmentRpcInput, - ) => Stream.Stream, EnvironmentRpcStreamFailure>; - return method(input); - }), - ), - ).pipe( + currentSession().pipe(Effect.map((session) => runStreamInSession(session, tag, input))), + ); +} + +export function runStreamInSession( + session: RpcSession, + tag: TTag, + input: EnvironmentRpcInput, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure | EnvironmentAuthorizationError, + EnvironmentSupervisor.EnvironmentSupervisor +> { + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Stream.Stream, EnvironmentRpcStreamFailure>; + return Stream.unwrap(authorizeRequest(tag, input).pipe(Effect.map(() => method(input)))).pipe( Stream.withSpan("EnvironmentRpc.runStream", { attributes: { "rpc.method": tag }, }), diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index f3c282d8bd312..b01cd406d053a 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -10,12 +10,15 @@ import { UsageLimitSourceId, type ServerConfig, type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, type ServerLifecycleWelcomePayload, + type ServerSelfUpdateOutcome, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -25,6 +28,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -48,7 +52,6 @@ import type { RpcSession } from "../rpc/session.ts"; import { applyServerWelcomeEvent, createServerEnvironmentAtoms, - serverUpdateStateAtom, makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, @@ -61,6 +64,7 @@ import { resolveServerWelcomeState, resolveServerUpdateProgressResult, serverUpdateStateForProgressEvent, + serverUpdateStateAtom, serverUpdateStateForServerVersion, validateServerUpdateReadyEvent, waitForNextEnvironmentReconnect, @@ -254,22 +258,37 @@ describe("accepted server update recovery", () => { capabilities: { serverSelfUpdate: method, serverSelfUpdateProgress: true }, }, } as ServerConfig; + const ready = (serverVersion: string) => + ({ + version: 1, + sequence: 1, + type: "ready", + payload: { at: "2026-09-01T00:00:00.000Z", environment: { serverVersion } }, + }) as Parameters[1]; + let requested = false; const client = { [WS_METHODS.subscribeServerConfig]: () => Stream.never, + // Before the request, a boot-service update reads the accepting server's ready. [WS_METHODS.subscribeServerLifecycle]: () => - Stream.fromEffect(Deferred.succeed(observing, undefined)).pipe( - Stream.drain, - Stream.concat(Stream.fromQueue(readyEvents)), - Stream.tap((event) => - event.payload.environment.serverVersion === "0.0.30" - ? Deferred.succeed(oldReadyObserved, undefined) - : Effect.void, - ), - ), + requested + ? Stream.fromEffect(Deferred.succeed(observing, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(readyEvents)), + Stream.tap((event) => + event.payload.environment.serverVersion === "0.0.30" + ? Deferred.succeed(oldReadyObserved, undefined) + : Effect.void, + ), + ) + : Stream.make(ready("0.0.30")).pipe(Stream.concat(Stream.never)), [WS_METHODS.serverUpdateServerWithProgress]: () => - (loss === "no_progress" - ? Stream.empty - : Stream.make({ type: "progress" as const, stage: "downloading" as const }) + Stream.unwrap( + Effect.sync(() => { + requested = true; + return loss === "no_progress" + ? Stream.empty + : Stream.make({ type: "progress" as const, stage: "downloading" as const }); + }), ).pipe( Stream.concat( Stream.fromEffect( @@ -363,13 +382,6 @@ describe("accepted server update recovery", () => { status: "running", stage: "resuming", }); - const ready = (serverVersion: string) => - ({ - version: 1, - sequence: 1, - type: "ready", - payload: { at: "2026-09-01T00:00:00.000Z", environment: { serverVersion } }, - }) as Parameters[1]; yield* Queue.offer(readyEvents, ready("0.0.30")); yield* Deferred.await(oldReadyObserved); expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toMatchObject({ @@ -778,6 +790,20 @@ describe("server state projection", () => { validateServerUpdateReadyEvent(result, ready("rolled-back")), ); expect(rollback.message).toBe("prepared-timeout"); + const foreign = ready("rolled-back"); + const foreignRollback = yield* Effect.flip( + validateServerUpdateReadyEvent(result, { + ...foreign, + payload: { + ...foreign.payload, + updateOutcome: { ...foreign.payload.updateOutcome!, id: "manual-1" }, + }, + }), + ); + expect(foreignRollback).toMatchObject({ + status: "failed", + reason: "The service launcher resumed without committing the requested server version.", + }); }), ); @@ -1315,6 +1341,250 @@ describe("server state projection", () => { ); }); +describe("launcher update resume", () => { + const UPDATE_CONFIG = { + ...CONFIG, + environment: { + serverVersion: "0.0.30", + capabilities: { serverSelfUpdate: "boot-service", serverSelfUpdateProgress: true }, + }, + } as unknown as ServerConfig; + + const ready = (at: string, serverVersion: string, updateOutcome?: ServerSelfUpdateOutcome) => + ({ + version: 1, + sequence: 1, + type: "ready", + payload: { at, environment: { serverVersion }, ...(updateOutcome ? { updateOutcome } : {}) }, + }) as ServerLifecycleStreamEvent; + const after = (delay: Duration.Input, event: ServerLifecycleStreamEvent) => + Stream.fromEffect(Effect.as(Effect.sleep(delay), event)); + const committed = (id: string, fromVersion: string, targetVersion: string) => ({ + id, + fromVersion, + targetVersion, + status: "committed" as const, + }); + const ACCEPTING = ready("2026-08-01T00:00:00.000Z", "0.0.30"); + const REPLACEMENT = ready("2026-08-01T00:05:00.000Z", "0.0.31"); + + // Lifecycle subscriptions opened before the update is acknowledged reach the accepting + // process; later ones reach whatever the host has become. + const launchUpdate = Effect.fn("ServerUpdateTest.launchUpdate")(function* (host: { + readonly accepting: Stream.Stream; + readonly resumed: Stream.Stream; + readonly reconnectAfterBaseline?: boolean; + }) { + const started = yield* Deferred.make(); + const resumeSubscribed = yield* Deferred.make(); + let acknowledged = false; + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.never, + [WS_METHODS.serverUpdateServerWithProgress]: () => + Stream.fromEffect( + Deferred.succeed(started, undefined).pipe( + Effect.andThen( + Effect.sync(() => { + acknowledged = true; + return { + type: "complete" as const, + result: { + targetVersion: "0.0.31", + method: "boot-service" as const, + updateId: "update-1", + }, + }; + }), + ), + ), + ), + [WS_METHODS.subscribeServerLifecycle]: () => { + const events = acknowledged + ? Deferred.succeed(resumeSubscribed, undefined).pipe(Effect.as(host.resumed)) + : Effect.succeed(host.accepting); + const reconnect = + !acknowledged && host.reconnectAfterBaseline === true + ? SubscriptionRef.set(supervisor.session, Option.some(updateSession())) + : Effect.void; + return Stream.unwrap( + Deferred.succeed(started, undefined).pipe(Effect.andThen(events)), + ).pipe(Stream.concat(Stream.never), Stream.ensuring(reconnect)); + }, + } as unknown as WsRpcProtocolClient; + const updateSession = (): RpcSession => ({ + ...session(client), + initialConfig: Effect.succeed(UPDATE_CONFIG), + }); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(updateSession())), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + type Environments = EnvironmentRegistry.EnvironmentRegistry["Service"]; + const run: Environments["run"] = (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runStream: Environments["runStream"] = (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environments = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + runStream, + followStream: runStream, + stateChanges: () => Stream.never, + retryNow: () => Effect.void, + } as unknown as Environments); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + // Share the test clock so the command's timers and the host's restart run on TestClock. + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments), + Layer.succeed(Persistence.EnvironmentCacheStore, cache), + Layer.succeed(Clock.Clock, yield* Clock.Clock), + Layer.succeed(Scheduler.Scheduler, yield* Scheduler.Scheduler), + ), + ); + const initialConfigValueAtom = Atom.make(UPDATE_CONFIG); + const atoms = createServerEnvironmentAtoms(runtime, { + initialConfigValueAtom: () => initialConfigValueAtom, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const updateStateAtom = serverUpdateStateAtom(TARGET.environmentId); + yield* AtomRegistry.mount(registry, updateStateAtom); + + yield* Effect.promise(() => + atoms.updateServer.run(registry, { + environmentId: TARGET.environmentId, + input: { targetVersion: "0.0.31" }, + }), + ).pipe(Effect.forkScoped); + return { + // The host saw its first call: the update request or a lifecycle subscription. + started: Deferred.await(started), + resumeSubscribed: Deferred.await(resumeSubscribed), + state: () => registry.get(updateStateAtom), + }; + }); + + const UNCOMMITTED_RESUME = { + status: "failed", + stage: "resuming", + fromVersion: "0.0.30", + targetVersion: "0.0.31", + message: "The service launcher resumed without committing the requested server version.", + }; + + it.effect("fails promptly when a server the launcher did not start comes back", () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + accepting: Stream.make(ACCEPTING), + resumed: after("1 second", ACCEPTING).pipe(Stream.concat(after("1 minute", REPLACEMENT))), + }); + yield* update.resumeSubscribed; + + yield* TestClock.adjust("1 second"); + expect(update.state()).toMatchObject({ status: "running", stage: "resuming" }); + yield* TestClock.adjust("1 minute"); + expect(update.state()).toEqual(UNCOMMITTED_RESUME); + }), + ), + ); + + it.effect( + "fails promptly when the accepting server's replay arrives after a quick acknowledgement", + () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + accepting: after("3 seconds", ACCEPTING), + resumed: after("1 second", REPLACEMENT), + }); + yield* update.started; + yield* TestClock.adjust("3 seconds"); + yield* update.resumeSubscribed; + + yield* TestClock.adjust("1 minute"); + expect(update.state()).toEqual(UNCOMMITTED_RESUME); + }), + ), + ); + + it.effect( + "waits for this update's outcome when a reconnect lands between the baseline and the request", + () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + reconnectAfterBaseline: true, + accepting: Stream.make(ACCEPTING), + // The process that accepted the update over the new session replays its own ready first. + resumed: after("1 second", ready("2026-08-01T00:01:00.000Z", "0.0.30")).pipe( + Stream.concat( + after( + "1 minute", + ready( + "2026-08-01T00:05:00.000Z", + "0.0.31", + committed("update-1", "0.0.30", "0.0.31"), + ), + ), + ), + ), + }); + yield* update.resumeSubscribed; + + yield* TestClock.adjust("1 second"); + expect(update.state()).toMatchObject({ status: "running", stage: "resuming" }); + yield* TestClock.adjust("1 minute"); + expect(update.state()).toMatchObject({ status: "idle" }); + }), + ), + ); + + it.effect( + "sends the update without a baseline when the accepting server never replays ready", + () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + accepting: Stream.never, + resumed: after( + "1 second", + ready( + "2026-08-01T00:05:00.000Z", + "0.0.31", + committed("update-1", "0.0.30", "0.0.31"), + ), + ), + }); + yield* update.started; + + yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("1 second"); + expect(update.state()).toMatchObject({ status: "idle" }); + }), + ), + ); +}); + describe("provider setup commands", () => { it.effect("starts installs for two instances in one environment separately", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 6b757ba8d15cf..40256c327b29e 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -43,10 +43,13 @@ import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as Persistence from "../platform/persistence.ts"; import { runCachePersistence } from "./cachePersistence.ts"; import { + currentSession, isRpcClientError, request, requestGuarded, + requestInSession, runStream, + runStreamInSession, subscribe, subscribeDynamicWithSession, type EnvironmentRpcInput, @@ -134,6 +137,8 @@ export class ServerUpdateTerminalError extends Schema.TaggedError { if (result.updateId === undefined) return Effect.void; - const outcome = event.payload.updateOutcome; + const reported = event.payload.updateOutcome; + const outcome = reported?.id === result.updateId ? reported : undefined; if ( - outcome?.id === result.updateId && - outcome.status === "committed" && + outcome?.status === "committed" && outcome.targetVersion === result.targetVersion && event.payload.environment.serverVersion === result.targetVersion ) { @@ -859,6 +871,17 @@ export function createServerEnvironmentAtoms( const desktopCommitStarting = yield* Deferred.make(); const desktopReconnectObserverArmed = yield* Deferred.make(); const desktopReconnected = yield* Deferred.make(); + let accepting: { readonly session: RpcSession; readonly at: string } | undefined; + let acceptedAt: string | undefined; + // The baseline only describes the accepting process if the request rides its session, + // so the session read here is the one the request is sent on. + const acceptingSession = currentSession().pipe( + Effect.tap((session) => + Effect.sync(() => { + acceptedAt = accepting?.session === session ? accepting.at : undefined; + }), + ), + ); yield* Deferred.await(desktopCommitStarting).pipe( Effect.andThen( environmentRegistry.stateChanges(target.environmentId).pipe( @@ -885,6 +908,28 @@ export function createServerEnvironmentAtoms( fromVersion, targetVersion, }); + // Only launcher updates carry an updateId. Read before the request so a ready from + // after it can never pose as the accepting process. + if (currentConfig?.environment.capabilities.serverSelfUpdate === "boot-service") { + accepting = yield* environmentRegistry + .followStream( + target.environmentId, + subscribeDynamicWithSession(WS_METHODS.subscribeServerLifecycle, () => + Effect.succeed({}), + ), + ) + .pipe( + Stream.filter( + (entry): entry is readonly [RpcSession, ServerLifecycleStreamReadyEvent] => + entry[1].type === "ready", + ), + Stream.map(([session, ready]) => ({ session, at: ready.payload.at })), + Stream.runHead, + Effect.timeoutOption(SERVER_UPDATE_ACCEPTING_READY_TIMEOUT), + Effect.map((head) => Option.getOrUndefined(Option.flatten(head))), + Effect.orElseSucceed(() => undefined), + ); + } const supportsProgress = currentConfig?.environment.capabilities.serverSelfUpdateProgress === true; @@ -897,7 +942,17 @@ export function createServerEnvironmentAtoms( const streamExit = yield* environmentRegistry .runStream( target.environmentId, - runStream(WS_METHODS.serverUpdateServerWithProgress, target.input), + Stream.unwrap( + acceptingSession.pipe( + Effect.map((session) => + runStreamInSession( + session, + WS_METHODS.serverUpdateServerWithProgress, + target.input, + ), + ), + ), + ), ) .pipe( Stream.runForEach((event) => @@ -937,7 +992,14 @@ export function createServerEnvironmentAtoms( : yield* Effect.gen(function* () { const selfUpdateMethod = currentConfig?.environment.capabilities.serverSelfUpdate; const exit = yield* environmentRegistry - .run(target.environmentId, request(WS_METHODS.serverUpdateServer, target.input)) + .run( + target.environmentId, + acceptingSession.pipe( + Effect.flatMap((session) => + requestInSession(session, WS_METHODS.serverUpdateServer, target.input), + ), + ), + ) .pipe(Effect.exit); if (Exit.isSuccess(exit)) { return exit.value; @@ -1014,7 +1076,7 @@ export function createServerEnvironmentAtoms( .pipe( Stream.filter( (event): event is ServerLifecycleStreamReadyEvent => - event.type === "ready" && matchesServerUpdateResumeEvent(result, event), + event.type === "ready" && matchesServerUpdateResumeEvent(result, event, acceptedAt), ), Stream.runHead, Effect.timeoutOption(SERVER_UPDATE_RESUME_TIMEOUT), From cbc68c9dc1de5d13fadc204188a431a61e157da4 Mon Sep 17 00:00:00 2001 From: voida6 Date: Wed, 11 Nov 2026 23:43:00 +1100 Subject: [PATCH 43/56] fix(web): Stop shows a pending state and repeat clicks no longer resume (#20809) Co-authored-by: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Co-authored-by: Julius Marminge Co-authored-by: maria-rcks Co-authored-by: Claude Sonnet 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 3 + .../chat/ComposerFooterControls.tsx | 2 + .../ComposerPrimaryActions.browser.test.tsx | 144 ++++++++++++++++++ .../chat/ComposerPrimaryActions.tsx | 71 +++++++-- 4 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPrimaryActions.browser.test.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8ccd172dc0d90..04385a5a6b873 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3578,11 +3578,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerControls}
({ + useEnvironmentIdentificationMode: () => "none", + useClientSettings: () => false, +})); +vi.mock("../SidebarStageBackdrop", () => ({ + StageBackdropButtonArt: () => null, + useSidebarStageBackdropVariant: () => null, +})); + +import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; + +function setup() { + return { + compact: true, + canOperateThread: true, + pendingAction: null, + isRunning: true, + canInterrupt: true, + canResume: false, + isSendBusy: false, + sendDisabledReason: null, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: vi.fn(), + onInterrupt: vi.fn(), + onResume: vi.fn(), + onSubmitMessage: vi.fn(), + } satisfies ComponentProps; +} + +it.each([false, true])( + "keeps repeat clicks away from Resume and Send (draft: %s)", + async (draft) => { + const props = setup(); + const container = document.createElement("div"); + Object.assign(container.style, { display: "flex", justifyContent: "flex-end", width: "400px" }); + await using screen = await render(, { container }); + const stop = screen + .getByRole("button", { name: "Stop generation" }) + .element() as HTMLButtonElement; + const { x, y, width, height } = stop.getBoundingClientRect(); + const target = () => + document.elementFromPoint(x + width / 2, y + height / 2)?.closest("button"); + await act(() => stop.click()); + await act(() => + ( + screen.getByRole("button", { name: "Stopping", exact: true }).element() as HTMLButtonElement + ).click(), + ); + await expect + .element(screen.getByRole("button", { name: "Stopping", exact: true })) + .toBeDisabled(); + expect(props.onInterrupt).toHaveBeenCalledTimes(1); + await screen.rerender( + , + ); + await expect + .element(screen.getByRole("button", { name: "Stopped", exact: true })) + .toBeDisabled(); + expect(target()).toHaveAttribute("aria-label", "Stopped"); + await act(() => { + for (let i = 0; i < 20; i++) target()?.click(); + }); + expect(props.onResume).not.toHaveBeenCalled(); + expect(props.onSubmitMessage).not.toHaveBeenCalled(); + const action = screen + .getByRole("button", { name: draft ? "Submit message" : "Resume thread" }) + .element() as HTMLButtonElement; + await act(() => action.click()); + expect(draft ? props.onSubmitMessage : props.onResume).toHaveBeenCalledTimes(1); + await screen.rerender(); + await expect.element(screen.getByRole("button", { name: "Stop generation" })).toBeEnabled(); + }, +); + +it("allows retry after ten seconds and protects a late stop", async () => { + const props = setup(); + await using screen = await render(); + using _timers = fakeTimers(); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await act(() => vi.advanceTimersByTime(9_999)); + expect(screen.getByRole("button", { name: "Stopping", exact: true }).element()).toBeDisabled(); + await act(() => vi.advanceTimersByTime(1)); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await screen.rerender( + , + ); + expect(screen.getByRole("button", { name: "Stopped", exact: true }).element()).toBeDisabled(); + expect(props.onInterrupt).toHaveBeenCalledTimes(2); + expect(props.onResume).not.toHaveBeenCalled(); +}); + +it("keeps Stop pending across a permission failure", async () => { + const props = setup(); + await using screen = await render(); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await screen.rerender( + , + ); + await screen.rerender(); + await expect + .element(screen.getByRole("button", { name: "Stopping", exact: true })) + .toBeDisabled(); + expect(props.onInterrupt).toHaveBeenCalledTimes(1); +}); + +it("keeps a pending stop when the run id clears and resets it for a different run", async () => { + const props = setup(); + await using screen = await render(); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await screen.rerender(); + expect(screen.getByRole("button", { name: "Stopping", exact: true }).element()).toBeDisabled(); + await screen.rerender(); + expect(screen.getByRole("button", { name: "Stop generation" }).element()).toBeEnabled(); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index ec9ec10332b7a..8e28cc1dd6af6 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,4 +1,11 @@ -import { memo, type MouseEventHandler, type PointerEventHandler, type ReactNode } from "react"; +import { + memo, + useEffect, + useState, + type MouseEventHandler, + type PointerEventHandler, + type ReactNode, +} from "react"; import { CheckIcon, ChevronLeftIcon, @@ -36,6 +43,7 @@ interface ComposerPrimaryActionsProps { pendingAction: PendingActionState | null; isRunning: boolean; canInterrupt: boolean; + runId?: string | null; followUpBehavior?: "queue" | "steer"; canSteer?: boolean; alternateShortcutLabel?: string | null; @@ -76,6 +84,8 @@ const formatPendingPrimaryActionLabel = (input: { return input.questionIndex > 0 ? "Submit answers" : "Submit answer"; }; +const STOP_PENDING_MAX_MS = 10_000; + const messageActionPillClassName = "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full bg-message-action font-medium text-base text-message-action-foreground shadow-xs shadow-message-action/24 outline-none hover:bg-message-action-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 disabled:shadow-none sm:text-sm"; @@ -90,6 +100,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ pendingAction, isRunning, canInterrupt, + runId = null, followUpBehavior = "steer", canSteer = true, alternateShortcutLabel = null, @@ -110,6 +121,26 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ keepFullHistory = false, onToggleKeepFullHistory, }: ComposerPrimaryActionsProps) { + const [stopPhase, setStopPhase] = useState<"pending" | "timedOut" | "landed" | null>(null); + const [stopRunId, setStopRunId] = useState(runId); + if (runId !== null && stopRunId !== runId) { + setStopRunId(runId); + setStopPhase(null); + } else if (canOperateThread) { + // Losing permission also clears canInterrupt; it does not acknowledge the stop. + if ((stopPhase === "pending" || stopPhase === "timedOut") && !canInterrupt) + setStopPhase("landed"); + else if (stopPhase === "landed" && canInterrupt) setStopPhase(null); + } + const isStopDisabled = stopPhase === "pending" || stopPhase === "landed"; + const stopLabel = + stopPhase === "landed" ? "Stopped" : stopPhase === "pending" ? "Stopping" : "Stop generation"; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- timer that re-enables Stop if the interrupt never settles + useEffect(() => { + if (stopPhase !== "pending") return; + const timeout = setTimeout(() => setStopPhase("timedOut"), STOP_PENDING_MAX_MS); + return () => clearTimeout(timeout); + }, [stopPhase]); const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } : undefined; @@ -134,15 +165,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({