diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 260be6436c3e8..5343dbb754ff8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,10 +140,16 @@ jobs: - name: Start installing browser secret helper build libraries if: needs.changes.outputs.build != 'false' run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + # Fetch only the indexes these main and universe packages need. The full set is ~42 MB, + # which a slow mirror can stretch past the wait below. + sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \ + /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources ( set +e - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config xvfb + sudo apt-get update -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config xvfb echo "$?" > "$RUNNER_TEMP/apt-status" ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & @@ -287,10 +293,16 @@ jobs: # the background while Vite+ installs; the step before the tests waits for it. - name: Start installing browser secret helper build libraries run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + # Fetch only the indexes these main and universe packages need. The full set is ~42 MB, + # which a slow mirror can stretch past the wait below. + sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \ + /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources ( set +e - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + sudo apt-get update -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config echo "$?" > "$RUNNER_TEMP/apt-status" ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index c02be425c1ab7..53b0a74a1f5aa 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -88,7 +88,7 @@ jobs: axe --version - name: Install foldable simulator controls - run: npm install --global agent-device@0.21.25 + run: npm install --global agent-device@0.21.36 - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" diff --git a/.gitignore b/.gitignore index b6b793e020ef1..85b7c5b4bf02f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ apps/mobile/.android-cache/ artifacts/app-store/screenshots/ artifacts/mobile-e2e/ artifacts/mobile-behavior/ +scripts/.e2e/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 2effa629d25e0..87f21a8a0a2f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. -- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. For a bug a user can reproduce in the web client, `vp run scenarios:prove` shows it failing on base and passing on the fix in the real app; see [Scenarios](docs/operations/scenarios.md). - The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4a4884ba73e29..ab4e1ed382dab 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,7 +27,7 @@ "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", "ffi-rs": "1.3.2", - "playwright-core": "1.60.0", + "playwright-core": "1.64.1", "react-grab": "^0.1.32", "tldts": "catalog:" }, diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index ef9d2aad56795..206a143128a8e 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -116,6 +116,11 @@ export const make = Effect.gen(function* () { yield* electronApp.quit; return yield* Effect.interrupt; } + // Chromium opens these caches at the first event-loop turn, before ready. + yield* DesktopUserData.clearStaleGpuCaches({ + userDataPath, + electronVersion: process.versions.electron, + }); const pendingSecondInstances: Array = []; let handleSecondInstance = (argv: readonly string[]) => { diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 6a7473e2f1b1a..de3230de02624 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -49,6 +49,13 @@ export const make = FileSystem.makeNoop({ syncFs("makeDirectory", path, () => { NodeFS.mkdirSync(path, { recursive: options?.recursive ?? false }); }), + remove: (path, options) => + syncFs("remove", path, () => + NodeFS.rmSync(path, { + recursive: options?.recursive ?? false, + force: options?.force ?? false, + }), + ), writeFileString: (path, data, options) => syncFs("writeFileString", path, () => NodeFS.writeFileSync(path, data, { flag: options?.flag ?? "w" }), diff --git a/apps/desktop/src/app/DesktopUserData.test.ts b/apps/desktop/src/app/DesktopUserData.test.ts index 78bab4085627b..53e548bd1d41b 100644 --- a/apps/desktop/src/app/DesktopUserData.test.ts +++ b/apps/desktop/src/app/DesktopUserData.test.ts @@ -3,8 +3,9 @@ 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 * as PlatformError from "effect/PlatformError"; -import { resolveUserDataPath } from "./DesktopUserData.ts"; +import { clearStaleGpuCaches, resolveUserDataPath } from "./DesktopUserData.ts"; it.effect.each(["t3code", "T3 Code (Alpha)"])( "preserves Windows credential keys from %s without copying browser databases", @@ -40,3 +41,100 @@ it.effect.each(["t3code", "T3 Code (Alpha)"])( ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +const gpuCacheDirectories = [ + "GPUCache", + "GrShaderCache", + "ShaderCache", + "DawnGraphiteCache", + "DawnWebGPUCache", + "GPUPersistentCache", +]; + +it.effect.each([ + { profile: "written by Electron 44.10.0", marker: "44.10.0" }, + { profile: "with no recorded Electron version", marker: undefined }, +])("clears GPU caches $profile once when Electron 44.10.1 starts", ({ marker }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userDataPath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-gpu-cache-" }); + for (const name of gpuCacheDirectories) { + yield* fs.makeDirectory(path.join(userDataPath, name), { recursive: true }); + yield* fs.writeFileString(path.join(userDataPath, name, "data_0"), "44.10.0 shaders"); + } + yield* fs.makeDirectory(path.join(userDataPath, "Local Storage"), { recursive: true }); + yield* fs.writeFileString(path.join(userDataPath, "Local Storage", "LOCK"), "profile"); + yield* fs.writeFileString(path.join(userDataPath, "Local State"), "profile"); + if (marker !== undefined) { + yield* fs.writeFileString(path.join(userDataPath, "gpu-cache-electron-version"), marker); + } + + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + + const survivingCaches: Array = []; + for (const name of gpuCacheDirectories) { + if (yield* fs.exists(path.join(userDataPath, name))) survivingCaches.push(name); + } + assert.deepStrictEqual(survivingCaches, []); + assert.equal( + yield* fs.readFileString(path.join(userDataPath, "Local Storage", "LOCK")), + "profile", + ); + assert.equal(yield* fs.readFileString(path.join(userDataPath, "Local State")), "profile"); + + yield* fs.makeDirectory(path.join(userDataPath, "GPUCache")); + yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders"); + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + assert.equal( + yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")), + "44.10.1 shaders", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps GPU caches built on the first launch of a fresh install", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const appData = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-fresh-" }); + const userDataPath = path.join(appData, "t3code-v2"); + + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + yield* fs.makeDirectory(path.join(userDataPath, "GPUCache")); + yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders"); + yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" }); + + assert.equal( + yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")), + "44.10.1 shaders", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("retries on the next launch when a GPU cache cannot be removed", () => + Effect.gen(function* () { + const writes: Array = []; + const fileSystem = FileSystem.makeNoop({ + remove: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: path, + }), + ), + writeFileString: (path) => + Effect.sync(() => { + writes.push(path); + }), + }); + + yield* clearStaleGpuCaches({ userDataPath: "/profile", electronVersion: "44.10.1" }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + + assert.deepStrictEqual(writes, []); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/desktop/src/app/DesktopUserData.ts b/apps/desktop/src/app/DesktopUserData.ts index 99a082b438b0d..0124aa72e0ad5 100644 --- a/apps/desktop/src/app/DesktopUserData.ts +++ b/apps/desktop/src/app/DesktopUserData.ts @@ -95,3 +95,43 @@ export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPa return destinationPath; }, ); + +const GPU_CACHE_DIRECTORIES = [ + "GPUCache", + "GrShaderCache", + "ShaderCache", + "DawnGraphiteCache", + "DawnWebGPUCache", + "GPUPersistentCache", +]; + +// Keyed on Electron, not Chromium: Electron patches ship Chromium backports under the same Chrome version. +export const clearStaleGpuCaches = Effect.fn("desktop.userData.clearStaleGpuCaches")( + function* (input: { readonly userDataPath: string; readonly electronVersion: string }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const markerPath = path.join(input.userDataPath, "gpu-cache-electron-version"); + const cachedVersion = yield* fs.readFileString(markerPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.undefined, + ), + ); + if (cachedVersion?.trim() === input.electronVersion) return; + for (const name of GPU_CACHE_DIRECTORIES) { + yield* fs.remove(path.join(input.userDataPath, name), { recursive: true, force: true }); + } + yield* fs.makeDirectory(input.userDataPath, { recursive: true }); + yield* fs.writeFileString(markerPath, input.electronVersion); + }, + (effect, input) => + effect.pipe( + Effect.catch((error) => + Effect.logWarning("could not clear stale GPU caches", { + userDataPath: input.userDataPath, + electronVersion: input.electronVersion, + error: error.message, + }), + ), + ), +); diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts new file mode 100644 index 0000000000000..45f63b951f366 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -0,0 +1,67 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { assert, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +vi.mock("electron", () => ({})); + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopIpc from "./DesktopIpc.ts"; + +it.effect("answers only the main window's top frame", () => + Effect.gen(function* () { + const mainFrame = {}; + const invoke = new Map(); + const sync = new Map(); + const ipc = yield* DesktopIpc.DesktopIpc.pipe( + Effect.provide( + DesktopIpc.layer({ + removeHandler: (channel) => void invoke.delete(channel), + handle: (channel, listener) => void invoke.set(channel, listener), + removeAllListeners: (channel) => void sync.delete(channel), + on: (channel, listener) => void sync.set(channel, listener), + }).pipe( + Layer.provide( + Layer.mock(ElectronWindow.ElectronWindow, { + main: Effect.succeedSome({ + webContents: { id: 1, mainFrame }, + } as unknown as Electron.BrowserWindow), + }), + ), + ), + ), + ); + yield* ipc.handle( + DesktopIpc.makeIpcMethod({ + channel: "token", + payload: Schema.Void, + result: Schema.String, + handler: () => Effect.succeed("secret"), + }), + ); + yield* ipc.handleSync({ channel: "token-sync", handler: () => Effect.succeed("secret") }); + + const senders = { + app: { sender: { id: 1 }, senderFrame: mainFrame }, + appSubframe: { sender: { id: 1 }, senderFrame: {} }, + previewGuest: { sender: { id: 2 }, senderFrame: {} }, + }; + const results: Record = {}; + for (const [name, sender] of Object.entries(senders)) { + const event = { ...sender, returnValue: "unset" as unknown }; + sync.get("token-sync")!(event); + results[name] = [ + yield* Effect.promise(() => + Promise.resolve(invoke.get("token")!(sender, undefined)).catch(() => "rejected"), + ), + event.returnValue, + ]; + } + assert.deepEqual(results, { + app: ["secret", "secret"], + appSubframe: ["rejected", undefined], + previewGuest: ["rejected", undefined], + }); + }).pipe(Effect.scoped), +); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index 354acc5a81552..69d4f70c15a0e 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -1,17 +1,23 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; + export interface DesktopIpcInvokeEvent { readonly sender: { readonly id: number }; + readonly senderFrame?: unknown; } -export interface DesktopIpcSyncEvent { +export interface DesktopIpcSyncEvent extends DesktopIpcInvokeEvent { returnValue: unknown; } +export type DesktopIpcSenderGuard = (event: DesktopIpcInvokeEvent) => Effect.Effect; + export type DesktopIpcHandleListener = ( event: DesktopIpcInvokeEvent, raw: unknown, @@ -52,6 +58,15 @@ export class DesktopIpcUnregistrationError extends Schema.TaggedError()( + "DesktopIpcUntrustedSenderError", + { channel: Schema.String }, +) { + override get message(): string { + return `Rejected ${this.channel} from a sender that is not the app window.`; + } +} + export const DesktopIpcError = Schema.Union([ DesktopIpcRegistrationError, DesktopIpcUnregistrationError, @@ -80,7 +95,11 @@ export class DesktopIpc extends Context.Service< } >()("@t3tools/desktop/ipc/DesktopIpc") {} -export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => +// ipcMain is shared by every webContents, including preview guests that load arbitrary pages. +export const make = ( + ipcMain: DesktopIpcMain, + isTrustedSender: DesktopIpcSenderGuard, +): DesktopIpc["Service"] => DesktopIpc.of({ handle: Effect.fn("desktop.ipc.registerInvoke")(function* ({ channel, @@ -98,6 +117,9 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => runPromise( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel }); + if (!(yield* isTrustedSender(event))) { + return yield* new DesktopIpcUntrustedSenderError({ channel }); + } return yield* handler(raw, event); }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), ), @@ -131,6 +153,11 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => event.returnValue = runSync( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel }); + // Throwing here would surface as an uncaught main-process exception. + if (!(yield* isTrustedSender(event))) { + yield* Effect.logWarning("Rejected sync IPC from an untrusted sender."); + return undefined; + } return yield* handler(); }).pipe( Effect.annotateLogs({ channel }), @@ -152,7 +179,28 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => }), }); -export const layer = (ipcMain: DesktopIpcMain) => Layer.succeed(DesktopIpc, make(ipcMain)); +export const layer = (ipcMain: DesktopIpcMain) => + Layer.effect( + DesktopIpc, + Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + return make(ipcMain, (event) => + Effect.map( + electronWindow.main, + (main) => + Option.isSome(main) && + main.value.webContents.id === event.sender.id && + main.value.webContents.mainFrame === event.senderFrame, + ), + ); + }), + ); + +export const layerTest = (ipcMain: DesktopIpcMain) => + Layer.succeed( + DesktopIpc, + make(ipcMain, () => Effect.succeed(true)), + ); export interface DesktopIpcMethodRegistration< Payload, diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts index 7126b1cd31945..b3c85c9ead283 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.test.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -80,7 +80,7 @@ it.effect("validates IPC and clears on native focus, quit, and disposal", () => Effect.provideService(HostProcess.Platform, "linux"), Effect.provide([ ElectronApp.layer, - DesktopIpc.layer({ + DesktopIpc.layerTest({ handle: (channel, handler) => { handlers.set(channel, handler); }, diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 802f2f3fb18b6..bd7e44bec8307 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -42,14 +42,17 @@ describe("preview IPC methods", () => { Effect.scoped( Effect.gen(function* () { let listener: DesktopIpc.DesktopIpcHandleListener | undefined; - const ipc = DesktopIpc.make({ - removeHandler: vi.fn(), - handle: (_channel, registered) => { - listener = registered; + const ipc = DesktopIpc.make( + { + removeHandler: vi.fn(), + handle: (_channel, registered) => { + listener = registered; + }, + removeAllListeners: vi.fn(), + on: vi.fn(), }, - removeAllListeners: vi.fn(), - on: vi.fn(), - }); + () => Effect.succeed(true), + ); yield* ipc.handle(method); return yield* Effect.promise(async () => structuredClone(await listener!({ sender: { id: 7 } }, { tabId: "capture-tab" })), diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index eebabd429025d..376f85d2baf9d 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -202,7 +202,7 @@ describe("installRelayTelemetryEnabled", () => { yield* installRelayTelemetryEnabled().pipe( Effect.provideService(HostProcess.Environment, env), Effect.provide( - Layer.mergeAll(DesktopIpc.layer(ipcMain), DesktopManagedPolicy.layerTest(policy)), + Layer.mergeAll(DesktopIpc.layerTest(ipcMain), DesktopManagedPolicy.layerTest(policy)), ), ); const event = { returnValue: undefined as unknown }; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d109518be15e4..e3971d7c74ea5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -139,7 +139,7 @@ const layerElectron = Layer.mergeAll( ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, - DesktopIpc.layer(Electron.ipcMain), + DesktopIpc.layer(Electron.ipcMain).pipe(Layer.provide(ElectronWindow.layer)), ); const layerDesktopFoundation = Layer.mergeAll( diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3e461303556ab..91d034cbd772b 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -697,6 +697,55 @@ describe("DesktopWindow", () => { }), ); + it.effect("ignores input that arrives after the window is destroyed", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const beforeInput = fakeWindow.webContentsListeners.get("before-input-event"); + const inputEvent = fakeWindow.webContentsListeners.get("input-event"); + if (!beforeInput || !inputEvent) { + return yield* Effect.die("input listeners were not registered"); + } + + // Electron throws on any member access of a destroyed BrowserWindow. + fakeWindow.isDestroyed.mockReturnValue(true); + Object.defineProperty(fakeWindow.window, "webContents", { + get: () => { + throw new TypeError("Object has been destroyed"); + }, + }); + + // A held Cmd+Q quits from the grace timer, so the trailing Q keyUp lands here. + assert.doesNotThrow(() => + beforeInput( + { preventDefault: () => {} }, + { + type: "keyUp", + isAutoRepeat: false, + key: "q", + meta: true, + control: false, + alt: false, + shift: false, + }, + ), + ); + assert.doesNotThrow(() => inputEvent({}, { type: "gestureScrollEnd" })); + }).pipe(Effect.provide(layer)); + }), + ); + // Chromium hands the main window's zoom level down to embedded preview // guests, so every app zoom has to put the preview browser back at its own // zoom or zooming the UI drags the previewed page with it. diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b8446f78740c4..73f5b41810dae 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -705,6 +705,7 @@ export const make = Effect.gen(function* () { }); window.webContents.on("before-input-event", (event, input) => { quitShortcutHandler(event, input); + if (window.isDestroyed()) return; // Cmd/Ctrl+W reaches the renderer; DevTools uses the native menu shortcut. window.webContents.setIgnoreMenuShortcuts( (environment.platform === "darwin" ? input.meta : input.control) && @@ -720,6 +721,7 @@ export const make = Effect.gen(function* () { } }); window.webContents.on("input-event", (_event, input) => { + if (window.isDestroyed()) return; if (input.type === "gestureScrollBegin") { window.webContents.send(TRACKPAD_SCROLL_BEGIN_CHANNEL); } else if (input.type === "gestureScrollEnd") { @@ -905,6 +907,8 @@ export const make = Effect.gen(function* () { void runPromise(revealWhenReady); }); + // Desktop IPC only answers the main window, so register it before its renderer can call in. + yield* electronWindow.setMain(window); loadApplication(); if (environment.isDevelopment) { window.webContents.openDevTools({ mode: "detach" }); @@ -923,7 +927,6 @@ export const make = Effect.gen(function* () { const createMain = Effect.gen(function* () { const window = yield* createWindow(); if (Option.isSome(window)) { - yield* electronWindow.setMain(window.value); mainClosedDuringStartup = false; yield* logWindowInfo("main window created"); } diff --git a/apps/mobile/src/components/ui/ListRow.tsx b/apps/mobile/src/components/ui/ListRow.tsx index de8bba51beb35..8f2ce11259bc9 100644 --- a/apps/mobile/src/components/ui/ListRow.tsx +++ b/apps/mobile/src/components/ui/ListRow.tsx @@ -1,17 +1,27 @@ -import { Platform } from "react-native"; +import { Platform, View } from "react-native"; import { useResolveClassNames } from "uniwind"; import { SymbolView } from "./AppSymbol"; import { ListRowLayout, type ListRowContentProps } from "./ListRowLayout"; +import { RadioIndicator } from "./RadioIndicator"; import { RowPressable } from "./RowPressable"; -export function ListRow(props: ListRowContentProps & { readonly onPress: () => void }) { +export function ListRow( + props: ListRowContentProps & { + readonly onPress: () => void; + /** Marks the row as the chosen option while it still navigates. */ + readonly selected?: boolean; + }, +) { const rippleStyle = useResolveClassNames("bg-subtle-strong"); return ( v + + {props.selected === undefined ? null : ( + + {Platform.OS === "android" ? ( + + ) : props.selected ? ( + + ) : null} + + )} + + } /> diff --git a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx index 9e4231ad65dd2..40a7d0b675af4 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx @@ -1,3 +1,8 @@ +import { + parsePullRequestAnchor, + pullRequestAnchorMatchesUrl, + resolvePullRequestReviewAnchor, +} from "@t3tools/shared/pullRequestAnchor"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { useAtomRefresh } from "@effect/atom-react"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; @@ -8,7 +13,7 @@ import { import { AsyncResult } from "effect/reactivity"; import type { PullRequestThreadComment } from "@t3tools/contracts"; import { useRef, useState } from "react"; -import { ScrollView, View } from "react-native"; +import { ScrollView, View, type ScrollViewInstance } from "react-native"; import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -138,11 +143,27 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss [...extra, ...(thread?.comments ?? [])].map((comment) => [comment.id, comment]), ).values(), ].sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + const scrollRef = useRef(null); + const pendingTop = useRef(null); + const positionedComment = useRef(null); + const location = parsePullRequestAnchor(params.navigationUrl); + const anchor = + location?.kind === "timeline" + ? resolvePullRequestReviewAnchor(location.anchor, [], [{ comments }]) + : null; const canReply = permissions.canReply && Boolean(thread); const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve"; return ( - + { + pendingTop.current = null; + }} + > { + if (pendingTop.current === null) return; + scrollRef.current?.scrollTo({ y: pendingTop.current, animated: false }); + pendingTop.current = null; + }} className="flex-1" contentInsetAdjustmentBehavior="automatic" keyboardShouldPersistTaps="handled" @@ -192,7 +219,25 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss This discussion is no longer available. ) : null} {comments.map((comment) => ( - + { + if ( + anchor === null || + !pullRequestAnchorMatchesUrl(anchor, comment.url) || + positionedComment.current === comment.id + ) + return; + positionedComment.current = comment.id; + pendingTop.current = event.nativeEvent.layout.y; + scrollRef.current?.scrollTo({ y: pendingTop.current, animated: false }); + }} + className={ + anchor !== null && pullRequestAnchorMatchesUrl(anchor, comment.url) + ? "-mx-3 gap-3 rounded-2xl border border-primary bg-primary/10 px-3 py-4" + : "gap-3 border-b border-border-subtle py-4" + } + > diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index f78cd9caa58cd..a58de3b87a781 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -1,3 +1,10 @@ +import { + parsePullRequestAnchor, + pullRequestAnchorMatchesUrl, + pullRequestDiffPathMatches, + pullRequestLinkCommit, + resolvePullRequestReviewAnchor, +} from "@t3tools/shared/pullRequestAnchor"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { Chip } from "../../components/ui/Chip"; import { pullRequestTabOffset } from "./pullRequestTabScroll"; @@ -154,8 +161,34 @@ function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityF }); return rows; })(); + const location = parsePullRequestAnchor(params.navigationUrl); + const anchor = + location?.kind !== "timeline" + ? null + : result.data + ? resolvePullRequestReviewAnchor( + location.anchor, + result.data.comments, + result.data.reviewThreads, + ) + : location.anchor; + const targetComment = anchor + ? result.data?.comments.find((comment) => pullRequestAnchorMatchesUrl(anchor, comment.url)) + : null; + const targetThread = anchor + ? result.data?.reviewThreads.find((thread) => + thread.comments.some((comment) => pullRequestAnchorMatchesUrl(anchor, comment.url)), + ) + : null; + const targetKey = targetThread + ? `discussion:${targetThread.id}` + : targetComment + ? `comment:${targetComment.id}` + : null; return { rows, + targetKey, + targetThread, hiddenCount: filtered?.hiddenCount ?? 0, pending: result.isPending && !result.data, error: result.error, @@ -334,7 +367,10 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps("overview"); + const [chosenSection, setSection] = useState<"overview" | "activity" | "guide">(() => { + const kind = parsePullRequestAnchor(params.navigationUrl)?.kind; + return kind === "timeline" || kind === "commits" ? "activity" : "overview"; + }); const requestedSection = useRef(chosenSection); const [listAnimationsReady, setListAnimationsReady] = useState(false); const [descriptionExpanded, setDescriptionExpanded] = useState(false); @@ -345,7 +381,14 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps !guidesEnabled && tab === "guide" ? "overview" : tab; const section = shown(chosenSection); - const [activityFilter, setActivityFilter] = useState(DEFAULT_ACTIVITY_FILTER); + const [activityFilter, setActivityFilter] = useState(() => { + const kind = parsePullRequestAnchor(params.navigationUrl)?.kind; + return kind === "timeline" + ? { ...DEFAULT_ACTIVITY_FILTER, bots: true, resolved: true } + : kind === "commits" + ? { ...DEFAULT_ACTIVITY_FILTER, kind: "commits" } + : DEFAULT_ACTIVITY_FILTER; + }); const activityContent = usePullRequestActivityRows(params, activityFilter); const scrollOffsets = useRef>({}); // The offset of the tab last shown, and which tab that was: a tab standing in for a hidden guide @@ -386,6 +429,74 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { + const target = parsePullRequestAnchor(params.navigationUrl); + if (target?.kind === "diff" || target?.kind === "code") { + const commit = pullRequestLinkCommit(params.navigationUrl); + navigation.navigate("PullRequestFile", { + ...params, + path: "", + ...(commit ? { commit } : {}), + }); + } else if (target?.kind === "timeline") { + selectSection("activity"); + setActivityFilter((filter) => ({ ...filter, kind: "all", bots: true, resolved: true })); + } else if (target?.kind === "commits") { + selectSection("activity"); + setActivityFilter((filter) => ({ ...filter, kind: "commits" })); + } else if (target?.kind === "checks") { + selectSection("overview"); + // Checks head Overview, which a reused screen may have scrolled past. + scrollViews.current.overview?.scrollToOffset({ offset: -contentTopInset, animated: false }); + } else if (target?.kind === "summary") { + selectSection("overview"); + } + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a link selects its native PR section or diff even when the route is reused + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- URL navigation selects the PR section and makes its target visible + followLink(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- each clicked URL is a fresh navigation request + }, [params.navigationUrl, params.navigationId]); + + const activityTargetIndex = activityContent.rows.findIndex( + (row) => row.key === activityContent.targetKey, + ); + const openedDiscussion = useRef(null); + const positionedActivity = useRef(null); + // Rows above the target measure in after the first jump, so it is held until the user scrolls. + const heldActivityRow = useRef(null); + const positionActivityRow = (index: number) => + void scrollViews.current.activity?.scrollToIndex({ + index, + viewPosition: 0, + viewOffset: contentTopInset + 12, + animated: false, + }); + const revealActivity = useEffectEvent(() => { + const key = `${params.navigationUrl}:${params.navigationId ?? 0}`; + if (activityContent.targetThread && openedDiscussion.current !== key) { + openedDiscussion.current = key; + navigation.navigate("PullRequestDiscussion", { + ...params, + discussionId: activityContent.targetThread.id, + }); + return; + } + // Later filter or refresh changes move the row; they must not pull the user back to it. + if (activityTargetIndex >= 0 && positionedActivity.current !== key) { + positionedActivity.current = key; + // Its top sits under the app bar; centering a long comment pushed its start off screen. + heldActivityRow.current = activityTargetIndex + 1; + positionActivityRow(activityTargetIndex + 1); + } + }); + const targetThreadId = activityContent.targetThread?.id; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals the requested activity once its asynchronous rows arrive + useEffect(() => { + revealActivity(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- reruns when activity resolves the URL target, even a thread the filter hides + }, [params.navigationUrl, params.navigationId, activityTargetIndex, targetThreadId]); const tabContent = activityContent; const filesY = useRef(0); const descriptionY = useRef(0); @@ -596,6 +707,13 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { + heldActivityRow.current = null; + } + : undefined + } onScroll={(event) => { const offset = event.nativeEvent.contentOffset.y; scrollOffsets.current[tab] = offset; @@ -619,6 +737,13 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { + const held = heldActivityRow.current; + if (tab === "activity" && held !== null) { + // A filter or refresh that moved the row ends the hold. + if (held !== activityTargetIndex + 1) heldActivityRow.current = null; + else positionActivityRow(held); + return; + } if ( tab !== "overview" || section !== "overview" || @@ -848,9 +973,20 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { (detail.data ?? route.params.initialSummary)?.provider, ).changeRequest; const result = usePullRequestDiff(route.params, terminology); + // Other screens open files with the link's params spread in; only the link's own entry follows it. + const linkAnchor = + route.params.path === "" && + (route.params.commit ?? null) === pullRequestLinkCommit(route.params.navigationUrl) + ? parsePullRequestAnchor(route.params.navigationUrl) + : null; + const linkPath = + linkAnchor?.kind === "diff" && result.parsed.kind === "files" + ? result.parsed.files.find((file) => pullRequestDiffPathMatches(linkAnchor.digest, file.path)) + ?.path + : undefined; const filter = usePullRequestFileFilter( result.parsed, - route.params.focus ? route.params.path : undefined, + linkPath ?? (route.params.focus ? route.params.path : undefined), ); const { refresh: refreshDetail } = detail; useFocusEffect(refreshDetail); @@ -922,15 +1058,30 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { code: row.content ?? "", }); }; - const [jumpPath, setJumpPath] = useState(route.params.path); + const [pickedPath, setJumpPath] = useState(null); + const jumpPath = pickedPath ?? linkPath ?? route.params.path; const requestedFile = result.parsed.kind === "files" ? result.parsed.files.find((file) => file.path === jumpPath) : undefined; const targetFile = data.files.find((file) => file.path === jumpPath) ?? - (!jumpPath || requestedFile ? data.files[0] : undefined); - const focus = route.params.focus; + (linkAnchor?.kind === "diff" && !linkPath + ? undefined + : !jumpPath || requestedFile + ? data.files[0] + : undefined); + const range = pickedPath === null && linkAnchor?.kind === "diff" ? linkAnchor.range : null; + const focus = + route.params.focus ?? + (range + ? { + side: range.side === "left" ? ("old" as const) : ("new" as const), + start: range.start, + end: range.end, + endSide: range.endSide === "left" ? ("old" as const) : ("new" as const), + } + : undefined); const focusedRows = guideFocusedRows(data.rows, targetFile?.id, focus); const focusIndex = focusedRows.length ? data.rows.findIndex((row) => row.id === focusedRows[0]?.id) @@ -941,7 +1092,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { // The filter the current jump was made under. Hiding or showing test files replaces the rows, // so a jump made under the other filter has to run again. Native applies the entry // destination before its first viewport, so the first one counts as made. - const jumpedUnder = useRef(filter.hideTestFiles); + const jumpedUnder = useRef(targetFile ? filter.hideTestFiles : null); const hasTestFiles = result.parsed.kind === "files" && result.parsed.files.some((file) => isTestChangedFile(file.path)); diff --git a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx index 52d5505c45604..03ba26f2247cf 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx @@ -1,3 +1,4 @@ +import { parsePullRequestAnchor } from "@t3tools/shared/pullRequestAnchor"; import { usePullRequestConnected } from "./usePullRequestConnected"; import { useAtomValue } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; @@ -102,7 +103,13 @@ export function PullRequestStatusMenu(props: RowProps) { } export function PullRequestChecksRow(props: RowProps) { - const [expanded, setExpanded] = useState(false); + const linked = parsePullRequestAnchor(props.params.navigationUrl)?.kind === "checks"; + const [expanded, setExpanded] = useState(linked); + const [seenNavigation, setSeenNavigation] = useState(props.params.navigationId); + if (props.params.navigationId !== seenNavigation) { + setSeenNavigation(props.params.navigationId); + if (linked) setExpanded(true); + } const checks = pullRequestChecksSummary(props.pr); const action = usePullRequestAction(props.params, props.pr); const definition = sourceControlClients.get(props.pr.provider); diff --git a/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx b/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx index e9f288ea9c60c..f3a491f2ef059 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx @@ -1,3 +1,7 @@ +import { + parsePullRequestAnchor, + pullRequestAnchorMatchesUrl, +} from "@t3tools/shared/pullRequestAnchor"; import { PullRequestEntryRow } from "./PullRequestEntryRow"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -74,7 +78,10 @@ function TimelineComment({ params: PullRequestParams; actions: CommentActions; }) { - const [expanded, setExpanded] = useState(false); + const target = parsePullRequestAnchor(params.navigationUrl); + const linked = + target?.kind === "timeline" && pullRequestAnchorMatchesUrl(target.anchor, comment.url); + const [expanded, setExpanded] = useState(linked); const { themeVariables: colors } = useAppearancePreferences(); const long = comment.body.length > 450 || comment.body.split("\n").length > 8; const content = useMemo( @@ -108,7 +115,13 @@ function TimelineComment({ ) : null} {preview.trim() || content.attachments.length ? ( - + , "navigate" | "dispatch">, actions: CommentActions, ) { + const linkTarget = parsePullRequestAnchor(params.navigationUrl); const discussionIds = new Set( activity.reviewThreads.flatMap((thread) => thread.comments.map((comment) => comment.id)), ); @@ -211,7 +225,19 @@ export function pullRequestTimelineRows( .map((comment) => ({ key: `comment:${comment.id}`, date: comment.createdAt, - content: , + content: ( + + ), })), ...activity.reviewThreads.map((thread) => ({ key: `discussion:${thread.id}`, diff --git a/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts new file mode 100644 index 0000000000000..49f09276d3659 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vite-plus/test"; +import type { NativeReviewDiffRow } from "../../diffs/nativeReviewDiffSurface"; +import { guideFocusedRows } from "./guideDiffFocus"; + +it("focuses a range that crosses diff sides in either direction", () => { + const rows: NativeReviewDiffRow[] = [ + { kind: "file", id: "file", fileId: "f" }, + { kind: "line", id: "a", fileId: "f", oldLineNumber: 12 }, + { kind: "line", id: "b", fileId: "f", oldLineNumber: 13 }, + { kind: "line", id: "c", fileId: "f", newLineNumber: 12 }, + { kind: "line", id: "d", fileId: "f", newLineNumber: 13 }, + { kind: "line", id: "other", fileId: "other", newLineNumber: 13 }, + ]; + expect( + guideFocusedRows(rows, "f", { side: "old", start: 12, endSide: "new", end: 13 }).map( + (row) => row.id, + ), + ).toEqual(["a", "b", "c", "d"]); + expect( + guideFocusedRows(rows, "f", { side: "new", start: 13, endSide: "old", end: 12 }).map( + (row) => row.id, + ), + ).toEqual(["a", "b", "c", "d"]); + expect( + guideFocusedRows(rows, "f", { side: "old", start: 12, end: 13 }).map((row) => row.id), + ).toEqual(["a", "b"]); + expect(guideFocusedRows(rows, "f", { side: "old", start: 99, endSide: "new", end: 13 })).toEqual( + [], + ); +}); diff --git a/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts index cc03bdbb60411..d840efb041f74 100644 --- a/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts +++ b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts @@ -2,6 +2,7 @@ import type { NativeReviewDiffRow } from "../../diffs/nativeReviewDiffSurface"; export type GuideDiffFocus = { readonly side: "old" | "new"; + readonly endSide?: "old" | "new"; readonly start: number; readonly end: number; }; @@ -12,6 +13,23 @@ export function guideFocusedRows( focus: GuideDiffFocus | undefined, ) { if (!fileId || !focus) return []; + if (focus.endSide && focus.endSide !== focus.side) { + const start = rows.findIndex( + (row) => + row.fileId === fileId && + (focus.side === "old" ? row.oldLineNumber : row.newLineNumber) === focus.start, + ); + const end = rows.findIndex( + (row) => + row.fileId === fileId && + (focus.endSide === "old" ? row.oldLineNumber : row.newLineNumber) === focus.end, + ); + return start < 0 || end < 0 + ? [] + : rows + .slice(Math.min(start, end), Math.max(start, end) + 1) + .filter((row) => row.fileId === fileId && row.kind === "line"); + } return rows.filter((row) => { const line = focus.side === "old" ? row.oldLineNumber : row.newLineNumber; return ( diff --git a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts index 8a97deffaf06a..8a95397cb3d3a 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts @@ -34,7 +34,13 @@ describe("native PR entry points", () => { context, projects, ), - ).toEqual({ ...context, host: "github.com", repository: "org/repo", number: 12 }); + ).toEqual({ + ...context, + host: "github.com", + repository: "org/repo", + number: 12, + navigationUrl: "https://github.com/org/repo/pull/12/files#diff", + }); expect( nativePullRequestDestination( "https://git.example/group/sub/repo/-/merge_requests/9", diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts index 6f5180d8ab6bc..6e49993a137f3 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts @@ -23,6 +23,8 @@ export type PullRequestParams = { readonly repository: string; readonly number: number; readonly host?: string; + readonly navigationUrl?: string; + readonly navigationId?: number; readonly initialSummary?: PullRequestListEntry | PullRequestSummary; }; diff --git a/apps/mobile/src/features/settings/CloudVoiceSection.tsx b/apps/mobile/src/features/settings/CloudVoiceSection.tsx index 684f8dbc83769..6ca61ece7b90a 100644 --- a/apps/mobile/src/features/settings/CloudVoiceSection.tsx +++ b/apps/mobile/src/features/settings/CloudVoiceSection.tsx @@ -21,7 +21,7 @@ import { import * as Schema from "effect/Schema"; import Constants from "expo-constants"; import { AsyncResult } from "effect/reactivity"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { ScrollView, TextInput, View } from "react-native"; import { Sheet } from "../../components/ui/Sheet"; import * as Dialog from "../../components/ui/Dialog"; @@ -30,6 +30,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { ListActionRow } from "../../components/ui/ListActionRow"; import { ListChoiceRow } from "../../components/ui/ListChoiceRow"; import { Chip } from "../../components/ui/Chip"; +import { ListSection } from "../../components/ui/ListSection"; import type { EnvironmentPresentation } from "../../state/environments"; import { useEnvironmentScope } from "../../state/session"; import { @@ -40,7 +41,6 @@ import { cloudVoiceModelsAtom, runSpeech, } from "../voice-input/voiceDestination"; -import { SettingsSection } from "./components/SettingsSection"; type CloudDestination = Extract; type Props = { environment: EnvironmentPresentation; active: CloudDestination | null }; @@ -48,23 +48,38 @@ const isApiKey = Schema.is(CloudSpeechApiKey); const isModelId = Schema.is(CloudSpeechModelId); const MODEL_ROWS = 6; -export function CloudVoiceSection({ environment, active }: Props) { - const [open, setOpen] = useState(false); +/** The cloud method row for one environment; opens the provider sheet. */ +export function CloudVoiceChoice({ + environment, + active, + open, + onOpenChange, +}: Props & { open: boolean; onOpenChange: (open: boolean) => void }) { + const usable = useAtomValue(cloudVoiceConnectionAtom(environment.environmentId)) !== null; return ( - - + setOpen(true)} + selected={active !== null} + disabled={!usable} + separated + onPress={() => onOpenChange(true)} /> {open && ( - setOpen(false)} /> + onOpenChange(false)} + /> )} - + ); } @@ -92,54 +107,70 @@ function ModelPicker({ const shown = showAll ? matches : matches.slice(0, MODEL_ROWS); const delisted = current !== undefined && !models.some((model) => model.id === current); return ( - <> - - {delisted ? ( - - OpenRouter no longer lists {current}. Dictation stays off until you choose another model. - - ) : null} + + + + {selected && !shown.some((model) => model.id === selected.id) ? ( {}} /> ) : null} - {shown.map((model, index) => ( + {shown.map((model) => ( 0} + separated disabled={disabled} onPress={() => onChange(model.id)} /> ))} {matches.length === 0 ? ( - No transcription model matches. + + + No transcription model matches. + + ) : matches.length > shown.length ? ( - setShowAll(true)} - /> + + setShowAll(true)} + /> + ) : null} - + ); } +/** A hairline above a row that has no `separated` prop of its own. */ +function Separated({ children }: { children: ReactNode }) { + return {children}; +} + function CloudChoices({ environment, active, onClose }: Props & { onClose: () => void }) { const id = environment.environmentId; const connection = useAtomValue(cloudVoiceConnectionAtom(id)); @@ -223,58 +254,35 @@ function CloudChoices({ environment, active, onClose }: Props & { onClose: () => onClose(); }; const catalog = AsyncResult.isSuccess(models) ? (models.value?.models ?? null) : null; - const content = - !AsyncResult.isSuccess(keys) || !keys.value || !connection ? ( - - ) : ( - CloudSpeechProvider.literals.map((provider) => { - if (editing && provider !== editing.provider) return null; - const label = CLOUD_SPEECH_LABELS[provider]; - const company = CLOUD_SPEECH_PROVIDER_NAMES[provider]; - const routed = provider === "openrouter"; - const disclosure = cloudSpeechDisclosure(provider, environment.label); - const saved = keys.value![provider]; - const isActive = active?.provider === provider; - const isEditing = editing?.provider === provider; - const modelChosen = - isModelId(model) && (catalog?.some((entry) => entry.id === model) ?? false); - const modelChanged = model !== (activeModel ?? ""); - const original = environment.serverConfig?.settings.cloudSpeechCredentials?.[provider]; - const keyChanged = - credential !== undefined && - !( - isSecretReference(credential) && - isSecretReference(original) && - original.provider === credential.provider && - original.reference === credential.reference - ); - const routedReady = - modelChosen && - (draft.trim() ? credential !== undefined : Boolean(saved)) && - (!isActive || modelChanged || keyChanged); - const saveRouted = () => { - if (!routedReady || !isModelId(model)) return; - const grantChanges = !isActive || modelChanged; - if (credential !== undefined && keyChanged) - return void changeKey(provider, credential, model); - // Model-only changes never write to the environment. - if (grantChanges) enable(() => chooseVoiceDestination(id, provider, model)); - onClose(); - }; - return ( - - {!isEditing && ( + const content = (() => { + if (!AsyncResult.isSuccess(keys) || !keys.value || !connection) + return ( + + + + ); + const saved = keys.value; + if (!editing) + return ( + + {CloudSpeechProvider.literals.map((provider, index) => { + const label = CLOUD_SPEECH_LABELS[provider]; + const routed = provider === "openrouter"; + const key = saved[provider]; + const isActive = active?.provider === provider; + return ( /> } selected={isActive} - disabled={saved === undefined || (!saved && !canManage) || saving} + separated={index > 0} + disabled={key === undefined || (!key && !canManage) || saving} description={ - saved === undefined + key === undefined ? "Update the environment to use this provider" : isActive && routed ? `${activeModel} · Tap to change model${canManage ? " or key" : ""}` - : isActive && saved && canManage + : isActive && key && canManage ? "Tap to manage key" - : saved + : key ? routed ? "API key saved · choose a model to use it" - : "API key saved on this environment" + : "API key saved" : canManage ? "Add an API key to use this provider" : "Ask an environment administrator to add an API key" } onPress={() => { if (routed) return edit(provider, !isActive); - if (!saved) return edit(provider, true); + if (!key) return edit(provider, true); if (isActive) { if (canManage) edit(provider, false); return; } Dialog.confirm({ title: `Use ${label} for dictation?`, - message: `${disclosure} Select This device to stop sharing.`, + message: `${cloudSpeechDisclosure(provider, environment.label)} Select This device to stop sharing.`, confirmText: `Use ${label}`, onConfirm: () => enable(pendingCloudVoiceChoice(id, provider)), }); }} /> - )} - {isEditing && routed && ( - <> - {disclosure} - {catalog ? ( - + ); + + const provider = editing.provider; + const label = CLOUD_SPEECH_LABELS[provider]; + const company = CLOUD_SPEECH_PROVIDER_NAMES[provider]; + const routed = provider === "openrouter"; + const disclosure = cloudSpeechDisclosure(provider, environment.label); + const key = saved[provider]; + const isActive = active?.provider === provider; + const modelChosen = isModelId(model) && (catalog?.some((entry) => entry.id === model) ?? false); + const modelChanged = model !== (activeModel ?? ""); + const original = environment.serverConfig?.settings.cloudSpeechCredentials?.[provider]; + const keyChanged = + credential !== undefined && + !( + isSecretReference(credential) && + isSecretReference(original) && + original.provider === credential.provider && + original.reference === credential.reference + ); + const routedReady = + modelChosen && + (draft.trim() ? credential !== undefined : Boolean(key)) && + (!isActive || modelChanged || keyChanged); + const saveRouted = () => { + if (!routedReady || !isModelId(model)) return; + const grantChanges = !isActive || modelChanged; + if (credential !== undefined && keyChanged) + return void changeKey(provider, credential, model); + // Model-only changes never write to the environment. + if (grantChanges) enable(() => chooseVoiceDestination(id, provider, model)); + onClose(); + }; + const submit = () => { + if (routed) saveRouted(); + else if (credential) void changeKey(provider, credential); + }; + const keyFooter = + draft && !credential + ? (source?.invalidReferenceMessage ?? "Enter a valid API key.") + : routed + ? key && !credential + ? "Uses the API key saved on this environment. The model choice applies only to this device." + : `Anyone with dictation access to ${environment.label} can bill this key.` + : editing.enable + ? `Anyone with dictation access to ${environment.label} can bill this key.` + : `Updates the ${company} key on ${environment.label}. Your dictation destination stays the same.`; + return ( + <> + {disclosure} + {routed ? ( + catalog ? ( + + ) : ( + + + + ) + ) : null} + {canManage ? ( + + + {sources.length > 0 && ( + + - ) : ( - { + setDraft(""); + setSourceId(null); + }} /> - )} - - )} - {isEditing && (canManage || routed) && ( - <> - {canManage && sources.length > 0 && ( - + {sources.map((candidate) => ( { setDraft(""); - setSourceId(null); + setSourceId(candidate.id); }} /> - {sources.map((candidate) => ( - { - setDraft(""); - setSourceId(candidate.id); - }} - /> - ))} - - )} - {canManage && ( - { - if (routed) saveRouted(); - else if (credential) void changeKey(provider, credential); - }} - value={draft} - onChangeText={setDraft} - className="mx-4 rounded-lg bg-background px-3 py-2 text-foreground" - /> - )} - {routed && !saved && !canManage ? ( - {}} - /> - ) : ( - { - if (routed) saveRouted(); - else if (credential) void changeKey(provider, credential); - }} - /> - )} - {saved && canManage && ( - - Dialog.confirm({ - title: `Remove the ${label} key from ${environment.label}?`, - message: "Everyone using it for dictation will lose access.", - confirmText: "Remove key", - destructive: true, - onConfirm: () => void changeKey(provider, null), - }) - } - /> - )} - - + ))} + + )} + + + + ) : null} + {canManage || routed ? ( + + {routed && !key && !canManage ? ( + {}} + /> + ) : ( + )} - - ); - }) + {key && canManage ? ( + + + Dialog.confirm({ + title: `Remove the ${label} key from ${environment.label}?`, + message: "Everyone using it for dictation will lose access.", + confirmText: "Remove key", + destructive: true, + onConfirm: () => void changeKey(provider, null), + }) + } + /> + + ) : null} + + ) : null} + + + + ); + })(); return ( {content} diff --git a/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx index 5f7328214cb16..4b0ca00e88666 100644 --- a/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx @@ -24,13 +24,14 @@ import * as Dialog from "../../components/ui/Dialog"; import { useEnvironments, type EnvironmentPresentation } from "../../state/environments"; import { chooseVoiceDestination, + cloudVoiceConnectionAtom, voiceConnectionAtom, voiceGrantAtom, - voiceSpeechStatusAtom, hasEnvironmentVoiceCapture, } from "../voice-input/voiceDestination"; -import { VoiceModelSection } from "./VoiceModelSection"; -import { CloudVoiceSection } from "./CloudVoiceSection"; +import { LocalModelChoice } from "./VoiceModelSection"; +import { CloudVoiceChoice } from "./CloudVoiceSection"; +import type { VoiceGrant } from "../voice-input/voiceGrantStorage"; import type { EnvironmentId } from "@t3tools/contracts"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; @@ -127,45 +128,81 @@ function saveDestination(id: EnvironmentId | null) { function EnvironmentChoice({ environment, selected, + onCloudOnly, }: { environment: EnvironmentPresentation; selected: boolean; + /** Called instead of a local grant when the environment only offers cloud transcription. */ + onCloudOnly: () => void; }) { - const usable = useAtomValue(voiceConnectionAtom(environment.environmentId)) !== null; - const status = AsyncResult.getOrElse( - useAtomValue(voiceSpeechStatusAtom(environment.environmentId)), - () => null, - ); + const local = useAtomValue(voiceConnectionAtom(environment.environmentId)) !== null; + const cloud = useAtomValue(cloudVoiceConnectionAtom(environment.environmentId)) !== null; + const usable = local || cloud; return ( { - if (!selected) - Dialog.confirm({ - title: `Send microphone audio to ${environment.label}?`, - message: `Finished recordings will be sent to ${environment.displayUrl ?? environment.label} for transcription. You can switch back to This device at any time.`, - confirmText: "Use environment", - onConfirm: () => saveDestination(environment.environmentId), - }); + if (selected) return; + if (!local) return onCloudOnly(); + Dialog.confirm({ + title: `Send microphone audio to ${environment.label}?`, + message: `Finished recordings will be sent to ${environment.displayUrl ?? environment.label} for transcription. You can switch back to This device at any time.`, + confirmText: "Use environment", + onConfirm: () => saveDestination(environment.environmentId), + }); }} /> ); } +/** How the selected environment transcribes: its own model or a cloud provider. */ +function EnvironmentMethodSection({ + environment, + grant, + cloudOpen, + onCloudOpenChange, +}: { + environment: EnvironmentPresentation; + grant: VoiceGrant | null; + cloudOpen: boolean; + onCloudOpenChange: (open: boolean) => void; +}) { + const id = environment.environmentId; + const destination = grant?.destination.environmentId === id ? grant.destination : null; + const cloudCapable = + environment.serverConfig?.environment.capabilities.cloudVoiceTranscription === true || + destination?.kind === "cloud"; + return ( + + saveDestination(id)} + /> + {cloudCapable ? ( + + ) : null} + + ); +} + export function SettingsVoiceRouteScreen() { const transcriber = useAtomValue(localVoiceTranscriberAtom); const grant = useAtomValue(voiceGrantAtom); @@ -175,6 +212,15 @@ export function SettingsVoiceRouteScreen() { (environment) => environment.environmentId === grant.destination.environmentId, ) : undefined; + // An environment whose cloud sheet is open; it may not hold the grant yet. + const [cloudSheet, setCloudSheet] = useState(null); + // A sheet whose environment lost cloud transcription is gone, so its pending choice is too. + const sheetEnvironment = environments.find( + (environment) => + environment.environmentId === cloudSheet && + environment.serverConfig?.environment.capabilities.cloudVoiceTranscription === true, + ); + const methodEnvironment = sheetEnvironment ?? grantEnvironment; const insets = useSafeAreaInsets(); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -234,8 +280,11 @@ export function SettingsVoiceRouteScreen() { saveDestination(null)} + selected={!grant && !sheetEnvironment} + onPress={() => { + setCloudSheet(null); + saveDestination(null); + }} /> {hasEnvironmentVoiceCapture && environments.map((environment) => ( @@ -243,9 +292,11 @@ export function SettingsVoiceRouteScreen() { key={environment.environmentId} environment={environment} selected={ - grant?.destination.kind === "environment" && - grant.destination.environmentId === environment.environmentId + sheetEnvironment + ? sheetEnvironment.environmentId === environment.environmentId + : grant?.destination.environmentId === environment.environmentId } + onCloudOnly={() => setCloudSheet(environment.environmentId)} /> ))} {isReady && grant && !grantEnvironment ? ( @@ -259,30 +310,14 @@ export function SettingsVoiceRouteScreen() { /> ) : null} - {hasEnvironmentVoiceCapture && - environments - .filter( - (env) => - env.serverConfig?.environment.capabilities.cloudVoiceTranscription || - (grant?.destination.kind === "cloud" && - grant.destination.environmentId === env.environmentId), - ) - .map((environment) => ( - - ))} - {grantEnvironment && grant?.destination.kind === "environment" ? ( - + setCloudSheet(open ? methodEnvironment.environmentId : null) + } /> ) : null} {!grant && transcriber === null ? ( diff --git a/apps/mobile/src/features/settings/VoiceModelSection.tsx b/apps/mobile/src/features/settings/VoiceModelSection.tsx index 2403f113b2af8..0bee9748b19cb 100644 --- a/apps/mobile/src/features/settings/VoiceModelSection.tsx +++ b/apps/mobile/src/features/settings/VoiceModelSection.tsx @@ -29,7 +29,6 @@ import { voiceSpeechModelsAtom, voiceSpeechStatusAtom, } from "../voice-input/voiceDestination"; -import { SettingsSection } from "./components/SettingsSection"; const isDownloading = (model: EnvironmentSpeechModel) => model.state === "downloading" || model.state === "verifying"; @@ -272,51 +271,59 @@ export function VoiceModelRow(props: { ); } -export function VoiceModelSection(props: { +/** The environment's local-model method: chosen by its mark, opens the model list. */ +export function LocalModelChoice(props: { readonly environmentId: EnvironmentId; readonly label: string; + readonly selected: boolean; + readonly onSelect: () => void; }) { const navigation = useNavigation(); const voice = useVoiceModels(props.environmentId, props.label); const { status } = voice; - if (!voice.connection || !status) return null; - const title = `Transcription model on ${props.label}`; - if (!status.supported) { + const open = () => + navigation.navigate("SettingsVoiceModels", { environmentId: props.environmentId }); + if (!voice.connection || !status || !status.supported) return ( - - {null} - + {}} + /> ); - } const active = status.state === "missing-model" ? null : status; const pending = voice.models.find(isDownloading); const progress = pending?.downloaded === undefined ? undefined : pending.downloaded / pending.size; return ( - - - navigation.navigate("SettingsVoiceModels", { environmentId: props.environmentId }) - } - /> - + selected={props.selected} + onPress={() => { + if (!props.selected) props.onSelect(); + // Switching to an installed model needs no further step. + if (props.selected || !active) open(); + }} + /> ); } diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx index ef39ef51ff4cb..49f43cc2161b2 100644 --- a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -1,3 +1,4 @@ +import { providerClients } from "@t3tools/client-runtime/provider-clients"; import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; import { Pressable, ScrollView, View } from "react-native"; @@ -5,8 +6,6 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { AccountLimits, ResetCredits } from "../usage/UsageLimitsSection"; -const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; - export function ComposerUsageLimits({ report, environmentId, @@ -37,7 +36,7 @@ export function ComposerUsageLimits({ const resetCreditInput = account.resetCreditInput ?? (account.instanceId ? { instanceId: account.instanceId } : undefined); - const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver); + const driverLabel = providerClients.get(account.driver)?.label ?? account.driver; return ( const LINK_SYMBOL: AppSymbolName = { ios: "link", android: "desktop_windows" }; -/** The machine list's row for linking by an address typed in. */ -const ANOTHER_ADDRESS = "another-address"; - /** Whether the agent left the machine for the user to pick. */ const picksMachine = (item: LinkRequestItem) => item.environmentId === undefined && item.url === undefined; @@ -141,11 +141,6 @@ function PendingLinkRequest(props: { {item.reason.trim() ? ( {item.reason} ) : null} - {item.label !== undefined && item.url !== undefined ? ( - - {item.url} - - ) : null} {error !== null ? ( ; - readonly linked: boolean; - } - | { readonly kind: "address" }; - -function resolveTarget( - item: LinkRequestItem, - machines: ReadonlyArray, - choice: string | null, -): SheetTarget { - if (item.environmentId === undefined && item.url !== undefined) return { kind: "address" }; - const chosen = item.environmentId ?? choice; - if (chosen === null) return { kind: "none" }; - if (chosen === ANOTHER_ADDRESS) return { kind: "address" }; - const machine = machines.find((candidate) => candidate.environmentId === chosen); - return { - kind: "machine", - environmentId: machine?.environmentId ?? (chosen as EnvironmentId), - label: machine?.label ?? linkRequestTargetName(item), - urls: [...new Set([...(machine?.urls ?? []), ...(item.url === undefined ? [] : [item.url])])], - linked: machine?.linked ?? false, - }; -} - function LinkRequestSheet(props: { readonly environmentId: EnvironmentId; readonly item: LinkRequestItem; @@ -251,17 +215,22 @@ function LinkRequestSheet(props: { reportDefect: false, }); const selected = choice ?? linkRequestInitialMachine(item, machines); - const target = resolveTarget(item, machines, selected); + const target = linkRequestTarget(item, machines, selected); const targetName = target.kind === "machine" ? target.label : linkRequestTargetName(item); - // This phone can mint a code there when it is connected to that environment - // with a session that may create pairing links. const targetId = target.kind === "machine" ? target.environmentId : null; const connected = usePreparedConnection(targetId); const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope); - const canMint = - targetId !== null && Option.isSome(connected) && mayMint && !mintFailed.has(targetId); const needsAddress = target.kind === "address" || (target.kind === "machine" && target.urls.length === 0); + // This phone mints a code there when it is connected with a session that may + // create pairing links, and only for its own addresses: anything can answer + // as that machine at one typed in, so that one gets a code the user fetched. + const canMint = + targetId !== null && + !needsAddress && + Option.isSome(connected) && + mayMint && + !mintFailed.has(targetId); const useExisting = target.kind === "machine" && target.linked; const ready = target.kind !== "none" && @@ -288,10 +257,7 @@ function LinkRequestSheet(props: { } return linkRequestAnswerInput(item, { type: "link", - target: - target.kind === "address" - ? { url: address } - : { ...target, urls: needsAddress ? [address.trim()] : target.urls }, + target: target.kind === "machine" && target.urls.length > 0 ? target : { url: address }, access, pairingCode: code, }); @@ -335,12 +301,27 @@ function LinkRequestSheet(props: { Agents here can then launch, message and wait on threads there. It appears in that machine's Connections, where it can be revoked. - {!pickMachine && item.url !== undefined ? ( + {item.url !== undefined && target.kind === "machine" ? ( {item.url} ) : null} + {needsAddress && !useExisting && address.trim() ? ( + + Check this address + + This app cannot confirm it is one of your machines. The pairing code you paste is sent + there. + + + {linkDestination(address)} + + + ) : null} {pickMachine ? ( {machines.map((machine, index) => ( @@ -369,7 +350,7 @@ function LinkRequestSheet(props: { /> ) : null} - {needsAddress && !(item.url !== undefined && item.environmentId === undefined) ? ( + {needsAddress && item.url === undefined ? ( > = { - codex: "Codex", - claudeAgent: "Claude", - grok: "Grok", - antigravity: "Antigravity", -}; const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; function accountName(account: LimitAccount) { if (account.displayName) return account.displayName; - if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver); + if (!account.email) return providerClients.get(account.driver)?.label ?? account.driver; return emailInitials(account.email) || "Account"; } @@ -444,7 +439,7 @@ export function UsageLimitsSection({ - {DRIVER_LABEL[pool.driver] ?? pool.driver} + {providerClients.get(pool.driver)?.label ?? pool.driver} {windows.map((window) => { @@ -559,7 +554,9 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { - {account.displayName ?? DRIVER_LABEL[account.driver] ?? account.driver} + {account.displayName ?? + providerClients.get(account.driver)?.label ?? + account.driver} {account.email ? ( diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 214e64fab628b..be4bf0c929d11 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -11,7 +11,8 @@ import { type ServerProviderUsageWindow, } from "@t3tools/contracts"; import { - formatDuration, + type ResetCreditChoice, + resetCreditChoices, formatResetsIn, limitsNotice, paceOf, @@ -243,21 +244,11 @@ export function ResetCredits(props: { const [status, setStatus] = useState(null); if (dense && credits.availableCount === 0 && status === null) return null; - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - const summary = - credits.availableCount === 0 - ? "No reset credits banked" - : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; - - const redeem = async () => { + const redeem = async (choice: ResetCreditChoice) => { if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); - const result = await consume({ environmentId, input }); + const result = await consume({ environmentId, input: choice.input }); setBusy(false); if (result._tag === "Success") { setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); @@ -270,44 +261,44 @@ export function ResetCredits(props: { ); }; - const confirm = () => { + const confirm = (choice: ResetCreditChoice) => { if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; - Dialog.alert( - "Use a reset credit?", - "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", - [ - { text: "Cancel", style: "cancel" }, - { text: "Use credit", onPress: () => void redeem() }, - ], - ); + Dialog.alert("Use a reset credit?", choice.confirmDescription, [ + { text: "Cancel", style: "cancel" }, + { text: "Use credit", onPress: () => void redeem(choice) }, + ]); }; return ( - - {summary} - {credits.availableCount > 0 ? ( - - - {busy ? "Using…" : "Use reset"} - - - ) : null} + + {resetCreditChoices(credits, input, now).map((choice) => ( + + {choice.summary} + {choice.availableCount > 0 ? ( + confirm(choice)} + className={ + dense + ? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]" + : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]" + } + > + + {busy ? "Using…" : choice.actionLabel} + + + ) : null} + + ))} {!canManageProviders ? ( This connection cannot manage provider accounts. diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 54dfa536ff366..0097ab7ed77c0 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -321,12 +321,27 @@ describe("prepareTurnAttachments", () => { // regression: #9713 read file-backed image drafts before enabling them it("uploads a file-backed image from its owned copy without staging base64", async () => { + mocks.upload.mockImplementation( + async ( + _uri: string, + _url: string, + options: { onProgress?: (event: { bytesSent: unknown; totalBytes: unknown }) => void }, + ) => { + // expo-modules-jsi delivers native Int64 progress counts as BigInt. + options.onProgress?.({ bytesSent: BigInt(1), totalBytes: BigInt(4) }); + return { status: 204, body: "", headers: {} }; + }, + ); + const progress: Array = []; const prepared = await prepareTurnAttachments({ environmentId, attachments: [fileBackedImage], supportsImageUploads: true, + onUploadProgress: (_, value) => progress.push(value), }); + expect(progress).toEqual([0.25]); + expect(mocks.upload).toHaveBeenCalledWith( fileBackedImage.fileUri, "https://environment.example/api/attachments/upload/signed", diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index aef8dd4447513..57699af22819e 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -232,8 +232,10 @@ async function uploadFileBytes( signal, ...(onProgress ? { + // Native events carry Int64 counts, which expo-modules-jsi delivers as BigInt despite the `number` type. onProgress: ({ bytesSent, totalBytes }) => { - if (totalBytes > 0) onProgress(bytesSent / totalBytes); + const total = Number(totalBytes); + if (total > 0) onProgress(Number(bytesSent) / total); }, } : {}), diff --git a/apps/mobile/src/lib/pullRequestNavigation.ts b/apps/mobile/src/lib/pullRequestNavigation.ts index ecc25375b37d4..40c593b3cab1c 100644 --- a/apps/mobile/src/lib/pullRequestNavigation.ts +++ b/apps/mobile/src/lib/pullRequestNavigation.ts @@ -1,3 +1,4 @@ +import { parsePullRequestAnchor } from "@t3tools/shared/pullRequestAnchor"; import { resolveChangeRequestLinkTarget } from "@t3tools/client-runtime/change-request-links"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -24,6 +25,7 @@ export function nativePullRequestDestination( projectId: String(target.project.id), repository: target.repository, number: target.number, + ...(parsePullRequestAnchor(url) ? { navigationUrl: url } : {}), ...(target.host === undefined ? {} : { host: target.host }), }; } diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 3c336b0292389..50c90a05106a2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2,6 +2,7 @@ import type { ThreadPendingApproval, ThreadPendingUserInput, } from "@t3tools/client-runtime/state/thread-requests"; +import { linkRequestTargetName } from "@t3tools/client-runtime/link-request"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; @@ -624,7 +625,7 @@ function itemSummary( case "secret_request": return item.label; case "link_request": - return `Link ${item.label ?? item.url ?? "an environment"}`; + return `Link ${linkRequestTargetName(item)}`; case "dynamic_tool": { const classified = classifyToolActivity({ itemType: "dynamic_tool_call", diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index ca6edda06a3bc..6608b7b5493db 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -9,6 +9,9 @@ import { appAtomRegistry } from "../state/atom-registry"; import { environmentServerConfigsAtom } from "../state/server"; import { withKnownPullRequestSummary } from "../state/use-thread-pr"; +let linkNavigationId = 0; +const nextLinkNavigationId = () => ++linkNavigationId; + type PullRequestContext = { readonly environmentId: string; readonly projectId: string }; // Avoid useRoute so layouts without route context can open any thread's pull requests. @@ -24,7 +27,10 @@ export function useNavigateToPullRequest(options?: { readonly replace?: boolean serverConfigs.has(environmentId), ); if (!destination) return tryOpenExternalUrl(url, "pull-request"); - const params = withKnownPullRequestSummary(destination); + const params = withKnownPullRequestSummary({ + ...destination, + ...(destination.navigationUrl ? { navigationId: nextLinkNavigationId() } : {}), + }); // Android shows one page at a time; iOS opens it over the current page, so Back returns. if (Platform.OS === "android") selectDetail({ name: "PullRequest", params }); else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params)); diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index c9d1834a78bdb..e675193b6324e 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useAtomValue } from "@effect/atom-react"; import { deriveReportedModelSelection } from "@t3tools/client-runtime/state/thread-execution"; @@ -18,7 +19,7 @@ import { import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { guessThreadLinkRef, - hasThreadLinks, + prepareThreadLinks, parseThreadLinkHref, relabelThreadLinks, threadLinkCandidates, @@ -230,12 +231,13 @@ export function useSideChats(ref: ScopedThreadRef) { } export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { + const prepared = useMemo(() => prepareThreadLinks(markdown), [markdown]); const titles = useAtomValue( - hasThreadLinks(markdown) - ? threadLinkTitlesAtom(JSON.stringify(threadLinkRefs(markdown, environmentId).map(threadKey))) + prepared.matches.length > 0 + ? threadLinkTitlesAtom(JSON.stringify(threadLinkRefs(prepared, environmentId).map(threadKey))) : EMPTY_THREAD_LINK_TITLES_ATOM, ); - return relabelThreadLinks(markdown, environmentId, (ref) => titles.get(threadKey(ref))); + return relabelThreadLinks(prepared, environmentId, (ref) => titles.get(threadKey(ref))); } /** Keyed reads; every thread is read only to place a v1 link its message's environment lacks. */ diff --git a/apps/server/package.json b/apps/server/package.json index 284084065e61a..d7e4d21f08b82 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -67,7 +67,7 @@ "effect": "catalog:", "ffi-rs": "1.3.2", "node-pty": "^1.2.0-beta.15", - "playwright-core": "1.60.0", + "playwright-core": "1.64.1", "proper-lockfile": "4.1.2", "saxes": "6.0.0", "stream-chain": "^4.2.5", diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index b50f4dfbf2a40..e5b42557a1e10 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -12,7 +12,7 @@ const STALE_RUNTIME = const quoteShell = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; -/** agent-device 0.21.31's flags that take a value, so the launcher can find the command past them. */ +/** agent-device 0.21.36's flags that take a value, so the launcher can find the command past them. */ const VALUE_FLAGS: ReadonlyArray = [ "--activity", "--all", @@ -53,6 +53,7 @@ const VALUE_FLAGS: ReadonlyArray = [ "--header", "--hold-ms", "--host", + "--image-tag", "--in-app", "--include", "--interval-ms", @@ -79,6 +80,7 @@ const VALUE_FLAGS: ReadonlyArray = [ "--metro-port", "--network-profile", "--on-error", + "--os", "--os-version", "--out", "--pattern", @@ -101,6 +103,7 @@ const VALUE_FLAGS: ReadonlyArray = [ "--provider-geo-location", "--provider-language", "--provider-locale", + "--provider-network-hosts", "--provider-network-profile", "--provider-os-version", "--provider-project", diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 250e6cf14f3be..eb299f9e29cdd 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -209,6 +209,19 @@ export const isDeviceHubInstalled = (baseDir: string) => export const isAgentDeviceInstalled = (baseDir: string) => isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); +/** Entry of the first installed agent-device among `versions`; never installs. */ +export const installedAgentDeviceEntry = Effect.fn("DeviceToolchain.installedAgentDeviceEntry")( + function* (baseDir: string, versions: ReadonlyArray) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const version of versions) { + const paths = toolPaths(path, baseDir, { ...AGENT_DEVICE_SPEC, version }); + if (yield* isInstalled(fs, paths, version)) return paths.entryPath; + } + return null; + }, +); + export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* ( baseDir: string, running: { hub?: string; agent?: string } = {}, diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 6b86a4d032610..e133b4f9db36c 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -40,6 +40,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( stateDir: string, ) => Effect.Effect, avdOutput: ProcessRunner.ProcessRunOutput = processOutput(0), + previousDaemon?: typeof LocalDeviceHost.__testing.AgentDeviceDaemonFile.Type, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -47,6 +48,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const hub = { running: true, spawnFails: false }; const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); const daemonHealthy = yield* Ref.make(true); + const runs: Array = []; for (const [name, version, entry] of [ ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"], ["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"], @@ -57,6 +59,10 @@ const agentFixture = Effect.fn("agentFixture")(function* ( yield* fs.writeFileString(entryPath, ""); yield* fs.writeFileString(path.join(installDir, ".install-complete"), version!); } + if (previousDaemon) { + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString(path.join(stateDir, "daemon.json"), JSON.stringify(previousDaemon)); + } const host = yield* LocalDeviceHost.make().pipe( Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)), Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }), @@ -92,10 +98,12 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ), Effect.provideService(ProcessRunner.ProcessRunner, { - run: (input) => - input.args.includes("devices") + run: (input) => { + runs.push(input); + return input.args.includes("devices") ? startup(stateDir) - : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)), + : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); + }, }), Effect.provideService( HttpClient.HttpClient, @@ -114,7 +122,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, stateDir, hub, daemonHealthy }; + return { host, fs, baseDir, stateDir, hub, daemonHealthy, runs }; }); describe("AgentDevice startup", () => { @@ -276,6 +284,75 @@ describe("AgentDevice startup", () => { expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("stops a previous server's daemon with the agent-device version it recorded", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { host, fs, baseDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" }, + ); + const olderInstall = path.join(baseDir, "tools", "agent-device", "0.0.1"); + const olderEntry = path.join( + olderInstall, + "node_modules", + "agent-device", + "bin", + "agent-device.mjs", + ); + yield* fs.makeDirectory(path.dirname(olderEntry), { recursive: true }); + yield* fs.writeFileString(olderEntry, ""); + yield* fs.writeFileString(path.join(olderInstall, ".install-complete"), "0.0.1"); + yield* host.stop; + expect(runs.find((run) => run.args.includes("daemon"))?.args[0]).toBe(olderEntry); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + // regression: #33552 a daemon left by a previous server survived every later graceful quit + it.effect("stops a previous server's daemon with the current pin when its version is gone", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { host, baseDir, stateDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" }, + ); + yield* host.stop; + expect(runs.find((run) => run.args.includes("daemon"))?.args).toEqual([ + path.join( + baseDir, + "tools", + "agent-device", + AGENT_DEVICE_VERSION, + "node_modules", + "agent-device", + "bin", + "agent-device.mjs", + ), + "daemon", + "stop", + "--state-dir", + stateDir, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("drops a dead daemon's record without running agent-device when the host stops", () => + Effect.gen(function* () { + // Above the Linux and macOS PID limits, so never a live process. + const deadPid = 4_194_305; + const path = yield* Path.Path; + const { host, fs, stateDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: deadPid, version: AGENT_DEVICE_VERSION }, + ); + yield* host.stop; + expect(runs.filter((run) => run.args.includes("daemon"))).toEqual([]); + expect(yield* fs.exists(path.join(stateDir, "daemon.json"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index afb408f5cc0fb..c5097509dcade 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -47,7 +47,9 @@ import { isAgentDeviceInstalled, isDeviceHubInstalled, deviceToolVersions, + AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION, + installedAgentDeviceEntry, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; @@ -624,28 +626,44 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } }); - const stopAgentDeviceDaemon = ( - agentTool: { readonly entryPath: string; readonly nodePath: string } | null, - ) => - agentTool - ? runner - .run({ - command: agentTool.nodePath, - args: [ - agentTool.entryPath, - "daemon", - "stop", - "--state-dir", - agentDeviceStateDir(path, config.stateDir), - ], - env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, - timeout: Duration.seconds(10), - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.ignore) - : Effect.void; - - let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; + // The daemon may predate this process (an earlier server was killed, possibly on an + // older pin), so stop it with the CLI of its recorded version when that is installed. + const stopAgentDeviceDaemon = Effect.gen(function* () { + if (!(yield* fs.exists(daemonFilePath()))) return; + const recorded = yield* readDaemonFile().pipe(Effect.option); + if ( + Option.isSome(recorded) && + recorded.value.pid && + !(yield* isProcessAlive(recorded.value.pid)) + ) { + return yield* fs.remove(daemonFilePath(), { force: true }); + } + const entryPath = yield* installedAgentDeviceEntry(config.baseDir, [ + ...(Option.isSome(recorded) && recorded.value.version ? [recorded.value.version] : []), + AGENT_DEVICE_VERSION, + ]); + if (!entryPath) return; + const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( + Effect.provideService(HostProcess.Platform, hostPlatform), + ); + yield* runner.run({ + command: nodePath, + args: [ + entryPath, + "daemon", + "stop", + "--state-dir", + agentDeviceStateDir(path, config.stateDir), + ], + env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, + timeout: Duration.seconds(10), + timeoutBehavior: "timedOutResult", + }); + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.ignore, + ); const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, @@ -760,7 +778,6 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { }), ), ); - agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; yield* onPhase("starting"); const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe( @@ -843,7 +860,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const stopAgent: DeviceHost.DeviceHost["Service"]["stopAgent"] = startLock.withPermits(1)( Effect.gen(function* () { - yield* stopAgentDeviceDaemon(agentToolRef); + yield* stopAgentDeviceDaemon; yield* Ref.update(runningRef, (running) => running ? { ...running, agentDevice: null } : running, ); @@ -855,7 +872,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const running = yield* Ref.getAndSet(runningRef, null); yield* stopHub(running?.hub); yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); - yield* stopAgentDeviceDaemon(agentToolRef); + yield* stopAgentDeviceDaemon; }), ); diff --git a/apps/server/src/device/deviceToolVersions.json b/apps/server/src/device/deviceToolVersions.json index e441cc55a9d34..ce61669b26573 100644 --- a/apps/server/src/device/deviceToolVersions.json +++ b/apps/server/src/device/deviceToolVersions.json @@ -1,4 +1,4 @@ { - "agent-device": "0.21.31", - "expo-device-hub": "0.19.1" + "agent-device": "0.21.36", + "expo-device-hub": "0.22.1" } diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index d9e1d7d4c09e1..db0d3c91ad194 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -114,6 +114,7 @@ export type GitBranchPullRequest = NonNullable & { interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; + readonly projectId: ProjectId | null; readonly style: SourceControlWritingStyleSettings; } @@ -2156,6 +2157,7 @@ export const make = Effect.gen(function* () { ...(input.includeBranch ? { includeBranch: true } : {}), ...(policy ? { policy } : {}), modelSelection: input.settings.modelSelection, + projectId: input.settings.projectId, }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -2386,6 +2388,7 @@ export const make = Effect.gen(function* () { ...(threadContext ? { threadContext } : {}), ...(policy ? { policy } : {}), modelSelection: settings.modelSelection, + projectId: settings.projectId, }); const bodyFile = path.join( @@ -3231,6 +3234,7 @@ export const make = Effect.gen(function* () { settings.sourceControlWriterModelSelection === null ? Effect.succeed({ modelSelection: settings.textGenerationModelSelection, + projectId, style: settings.sourceControlWritingStyle, }) : providerRegistry.getProviders.pipe( @@ -3240,6 +3244,7 @@ export const make = Effect.gen(function* () { providers, projectId, ), + projectId, style: settings.sourceControlWritingStyle, })), ), diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 066722ea6625f..401b91f24e0c8 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -969,8 +969,8 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null { return `Asked the user for ${item.label}: ${item.secretStatus}.`; case "link_request": return item.linkStatus === "linked" - ? `Linked ${item.linkedLabel ?? item.label ?? item.url ?? "an environment"} (${item.linkedAccess}).` - : `Asked the user to link ${item.label ?? item.url ?? "an environment"}: ${item.linkStatus}.`; + ? `Linked ${item.linkedLabel ?? item.url ?? "an environment"} (${item.linkedAccess}).` + : `Asked the user to link ${item.url ?? "an environment"}: ${item.linkStatus}.`; case "subagent": return item.result ?? item.progress ?? item.prompt; case "dynamic_tool": diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index aae63afa9962e..b07bb23c85eb4 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -317,7 +317,7 @@ const colorForm = { const resumed = ( entries: ReadonlyArray, options?: { - readonly version?: "2.2.5"; + readonly version?: "2.2.5" | "2.2.6"; readonly goal?: OrchestrationV2ProviderThread["goal"]; readonly external?: boolean; readonly supervised?: boolean; @@ -326,6 +326,7 @@ const resumed = ( readonly resolveExtractedTextPath?: (path: string) => Effect.Effect; readonly fileSystem?: FileSystem.FileSystem; readonly onPrompt?: (input: unknown) => void; + readonly borrowers?: { current: number }; }, ) => Effect.gen(function* () { @@ -335,7 +336,7 @@ const resumed = ( out("session.get", { sessionID: SESSION }), replyData("session.get", sessionInfo()), ...noOpenRequests, - ...(options?.version === "2.2.5" + ...(options?.version !== undefined ? [out("rpc.call", ""), reply("rpc.call", { output: null })] : []), ...withInstructions([ @@ -352,6 +353,7 @@ const resumed = ( ], { ...(options?.version === undefined ? {} : { version: options.version }), + ...(options?.borrowers === undefined ? {} : { borrowers: options.borrowers }), ...(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }), ...(options?.onPrompt === undefined ? {} : { onPrompt: options.onPrompt }), ...(options?.external === undefined ? {} : { external: options.external }), @@ -2891,6 +2893,422 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect.each(["completed", "error", "cancelled"] as const)( + "retains native image job %s after its launch returns", + (outcome) => + Effect.gen(function* () { + const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-image" }; + const notice = (sessionID: string) => + event("session.inbox.enqueued", { + sessionID, + inboxID: `msg_image_${sessionID}`, + item: { + type: "synthetic", + delivery: "steer", + payload: { + description: "Image generation", + text: + outcome === "completed" + ? "Image outcome retained. Saved image: /work/opencode2/image.png (test/image)." + : "Image outcome retained", + metadata: { + source: "experimental_image_generate", + jobID: "image_test", + state: outcome, + path: "/work/opencode2/image", + }, + }, + }, + }); + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.tool.input.started", { ...tool, name: "experimental_image_generate" }), + event("session.tool.called", { ...tool, input: { prompt: "A tree" }, executed: false }), + event( + "session.tool.success", + { + ...tool, + content: [{ type: "text", text: "Job launched" }], + metadata: { + jobID: "image_test", + path: "/work/opencode2/image", + model: "test/image", + }, + executed: false, + }, + undefined, + 2, + ), + event("session.execution.succeeded", { sessionID: SESSION }), + notice("ses_other"), + notice(SESSION), + ...(outcome === "completed" + ? [ + out("image.read", { + path: "/api/fs/read/image.png", + "location[directory]": WORK, + }), + reply("image.read", { imageBase64: "iVBORw0KGgo=" }), + ] + : []), + ], + { external: true }, + ); + let launched = false; + const events = yield* runtime.events.pipe( + Stream.takeUntil((e) => { + if (e.type !== "provider_thread.updated") return false; + if ( + e.providerThread.pendingBackgroundTasks?.some((task) => task.taskId === "image_test") + ) + launched = true; + return launched && e.pendingBackgroundWork === false; + }), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const items = collected.flatMap((e) => + e.type === "turn_item.updated" && e.turnItem.nativeItemRef?.nativeId === "call-image" + ? [e.turnItem] + : [], + ); + assert.deepEqual( + items.map((item) => item.status), + [ + "running", + "running", + "running", + outcome === "error" ? "failed" : outcome === "cancelled" ? "interrupted" : "completed", + ], + ); + assert.include(JSON.stringify(items.at(-1)), "Image outcome retained"); + if (outcome === "completed") { + const item = items.at(-1); + assert.deepEqual(item?.type === "dynamic_tool" ? toolOutputImages(item.output) : [], [ + { mimeType: "image/png", data: "iVBORw0KGgo=" }, + ]); + } + const rosters = collected.flatMap((e) => + e.type === "provider_thread.updated" + ? [e.providerThread.pendingBackgroundTasks ?? []] + : [], + ); + assert.isTrue(rosters.some((tasks) => tasks.some((task) => task.taskId === "image_test"))); + assert.deepEqual(rosters.at(-1), []); + }).pipe(Effect.scoped), + ); + + it.effect.each(["history", "list", "late-preview"] as const)( + "recovers native image job outcomes from %s after reconnect", + (source) => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + ...(() => { + const frames: Array = reconnected({}, "succeeded", { + content: [ + { + type: "tool", + id: "call-image-gap", + name: "experimental_image_generate", + executed: false, + state: { + status: "completed", + input: { prompt: "A tree" }, + content: [{ type: "text", text: "Job launched" }], + metadata: { jobID: "image_gap", path: "/tmp/image", model: "test/image" }, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ], + }); + if (source !== "history") + frames.splice(2, 0, out("rpc.call", ""), reply("rpc.call", { output: null })); + return frames; + })(), + out("message.list", { sessionID: SESSION, type: "synthetic", limit: "100" }), + reply("message.list", { + data: + source !== "history" + ? [] + : [ + { + id: "msg_image_gap", + time: { created: 3 }, + type: "synthetic", + text: "Recovered image failure", + description: "Image generation", + metadata: { + source: "experimental_image_generate", + jobID: "image_gap", + state: "error", + path: "/tmp/image", + }, + }, + ], + cursor: {}, + }), + ...(source !== "history" + ? [ + out("imageJobs", { sessionID: SESSION, kind: "image" }), + reply("imageJobs", { + data: [ + { + kind: "image", + id: "image_gap", + sessionID: SESSION, + status: source === "late-preview" ? "completed" : "error", + path: "/tmp/image", + model: "test/image", + time: { started: 2, completed: 3 }, + }, + ], + }), + ] + : []), + ...(source === "late-preview" + ? [ + event("session.inbox.enqueued", { + sessionID: SESSION, + inboxID: "msg_image_late", + item: { + type: "synthetic", + delivery: "steer", + payload: { + description: "Image generation", + text: "Image request finished. Saved image: /tmp/image.png (test/image).", + metadata: { + source: "experimental_image_generate", + jobID: "image_gap", + state: "completed", + path: "/tmp/image", + }, + }, + }, + }), + out("image.read", { + path: "/api/fs/read/image.png", + "location[directory]": "/tmp", + }), + reply("image.read", { imageBase64: "iVBORw0KGgo=" }), + ] + : []), + ], + { ...(source !== "history" ? { version: "2.2.6" as const } : {}), external: true }, + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil( + (e) => + e.type === "turn_item.updated" && + e.turnItem.nativeItemRef?.nativeId === "call-image-gap" && + (source === "late-preview" + ? e.turnItem.type === "dynamic_tool" && + toolOutputImages(e.turnItem.output).length > 0 + : e.turnItem.status === "failed"), + ), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + assert.include( + JSON.stringify(collected.at(-1)), + source === "late-preview" + ? "Saved image: /tmp/image.png" + : source !== "history" + ? "Image generation error" + : "Recovered image failure", + ); + assert.isTrue( + collected.some( + (e) => + e.type === "provider_thread.updated" && + e.providerThread.pendingBackgroundTasks?.some((task) => task.taskId === "image_gap"), + ), + ); + }).pipe(Effect.scoped), + ); + + it.effect("settles a recovered image outcome when preview I/O is interrupted", () => + Effect.gen(function* () { + const reading = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + const fs = yield* FileSystem.FileSystem; + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + ...reconnected({}, "succeeded", { + content: [ + { + type: "tool", + id: "call-image-slow", + name: "experimental_image_generate", + executed: false, + state: { + status: "completed", + input: { prompt: "A tree" }, + content: [{ type: "text", text: "Launched" }], + metadata: { jobID: "image_slow", path: "/tmp/image", model: "test/image" }, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ], + }), + out("message.list", { sessionID: SESSION, type: "synthetic", limit: "100" }), + reply("message.list", { + data: [ + { + id: "msg_image_slow", + type: "synthetic", + time: { created: 3 }, + text: "Image request finished. Saved image: /tmp/image.png (test/image).", + description: "Image generation", + metadata: { + source: "experimental_image_generate", + jobID: "image_slow", + state: "completed", + path: "/tmp/image", + }, + }, + ], + cursor: {}, + }), + event("session.model.selected", { + sessionID: SESSION, + model: { providerID: "opencode", id: "big-pickle", variant: "recovery-observed" }, + }), + ], + { + fileSystem: { + ...fs, + stat: (path) => + path === "/tmp/image.png" + ? Deferred.succeed(reading, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ) + : fs.stat(path), + }, + }, + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil( + (e) => + e.type === "provider_thread.updated" && + e.providerThread.nativeMetadata?.modelSelection?.options?.some( + (option) => option.value === "recovery-observed", + ) === true, + ), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + yield* Deferred.await(reading); + yield* TestClock.adjust("5 seconds"); + yield* Deferred.await(interrupted); + const collected = yield* Fiber.join(events); + assert.isTrue( + collected.some( + (e) => + e.type === "turn_item.updated" && + e.turnItem.nativeItemRef?.nativeId === "call-image-slow" && + e.turnItem.status === "completed", + ), + ); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect.each(["root", "child"] as const)( + "settles %s native image tasks when reconnect attempts fail", + (owner) => + Effect.gen(function* () { + const attempts = yield* Effect.forEach([0, 1, 2, 3, 4], () => Deferred.make()); + let count = 0; + let failures = 0; + const borrowers = { + get current() { + return count; + }, + set current(next: number) { + if (next < count && count === 2) { + const attempt = attempts[failures++]; + if (attempt !== undefined) Deferred.doneUnsafe(attempt, Effect.void); + } + count = next; + }, + }; + const tool = { + sessionID: owner === "root" ? SESSION : CHILD, + assistantMessageID: "msg_assistant", + id: "call-image-lost", + }; + const { runtime, thread } = yield* resumed( + [ + ...(owner === "root" + ? [out("session.prompt", { sessionID: SESSION, text: "" }), promptAccepted] + : [ + ...backgroundLaunch(CHILD), + event("session.execution.started", { sessionID: CHILD }), + ]), + event("session.tool.input.started", { ...tool, name: "experimental_image_generate" }), + event( + "session.tool.success", + { + ...tool, + content: [{ type: "text", text: "Launched" }], + metadata: { jobID: "image_lost", path: "/tmp/image", model: "test/image" }, + executed: false, + }, + undefined, + 2, + ), + ...(owner === "child" + ? [event("session.execution.succeeded", { sessionID: CHILD })] + : []), + event("session.execution.succeeded", { sessionID: SESSION }), + { type: "runtime_exit", status: "success" }, + ], + { borrowers }, + ); + const events = yield* runtime.events.pipe(Stream.runCollect, Effect.forkScoped); + yield* runtime.startTurn(withLineage(thread)); + yield* Deferred.await(attempts[0]!); + for (const attempt of attempts.slice(1)) { + yield* TestClock.adjust("2 seconds"); + yield* Deferred.await(attempt); + } + const collected = yield* Fiber.join(events); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + assert.isTrue( + collected.some( + (e) => + e.type === "turn_item.updated" && + e.turnItem.nativeItemRef?.nativeId === + (owner === "root" ? tool.id : `${CHILD}:${tool.id}`) && + e.turnItem.status === "failed", + ), + ); + const rootSnapshots = collected.flatMap((e) => + e.type === "provider_thread.updated" && e.providerThread.id === thread.id ? [e] : [], + ); + assert.isFalse(rootSnapshots.at(-1)?.pendingBackgroundWork); + assert.deepEqual(rootSnapshots.at(-1)?.providerThread.pendingBackgroundTasks, []); + assert.isTrue( + rootSnapshots.some((e) => + e.providerThread.pendingBackgroundTasks?.some((task) => task.taskId === "image_lost"), + ), + ); + }).pipe(Effect.scoped), + ); + it.effect("projects successful native todo updates into one checklist", () => Effect.gen(function* () { const updates = [ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index e4ad66e76539f..2714b3a360d12 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -54,6 +54,10 @@ const operationOf = ( query: Record, body: unknown, ) => { + if (method === "GET" && path === "/api/experimental/job") + return { type: "imageJobs", input: query }; + if (method === "GET" && path.startsWith("/api/fs/read/")) + return { type: "image.read", input: { path, ...query } }; const rpc = /^\/api\/rpc\/([^/]+)\/([^/]+)$/.exec(path); if (method === "POST" && rpc !== null) return { @@ -176,6 +180,11 @@ const replayHttpClient = ( } // Recorded responses are the raw HTTP bodies; `null` is an empty 204. const body = await controller.response(operation.type); + if (typeof body === "object" && body !== null && "imageBase64" in body) { + return new Response(Buffer.from(String(body.imageBase64), "base64"), { + headers: { "content-type": "image/png" }, + }); + } if (body === "") return new Promise(() => {}); if (typeof body === "object" && body !== null && "status" in body && "body" in body) { return new Response(encodeJson(body.body), { diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 5cabb1c363722..a5c123157bae1 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -2575,7 +2575,8 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))( creationSource: outcome === "failed" ? "web" : "mcp", commandId: CommandId.make(`command:followup-send-${outcome}`), threadId: child.childThreadId, - senderThreadId: parentThreadId, + // `t3 thread send` and unscoped MCP sessions carry no sender thread. + ...(outcome === "completed" ? {} : { senderThreadId: parentThreadId }), messageId, text: "Check the same work again.", attachments: [], diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index eab5cda62027e..3157f55cacfa6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -8486,12 +8486,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (!startsAttempt) return; const child = yield* projectionStore.getThread(command.threadId); const parentThreadId = child.lineage.parentThreadId; + // A send with no sender thread comes from a client acting for the owner, like a user send. if ( child.lineage.relationshipToParent !== "subagent" || parentThreadId === null || (command.notification === undefined && command.createdBy !== "user" && command.usageLimitContinuationOfRunId === undefined && + command.senderThreadId !== undefined && command.senderThreadId !== parentThreadId) ) return; diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts index 6793421a64333..9366fdf193dbf 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -1,4 +1,5 @@ import { assert, it, describe } from "@effect/vitest"; +import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; import { EventId, MessageId, @@ -19,6 +20,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -31,6 +34,8 @@ import { } from "./threadHistoryPaging.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; +vi.mock("@t3tools/shared/threadFindText", { spy: true }); + const layerTest = Layer.mergeAll(EventStore.layer, ProjectionStore.layer, ProjectStore.layer).pipe( Layer.provideMerge(SqlitePersistence.layerMemory), ); @@ -460,6 +465,40 @@ describe("V2 thread find", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("keeps its caches while Find is in use and drops them after one idle minute", () => + Effect.gen(function* () { + const projection = yield* setup; + yield* putItems([item("one", 1, "idle needle one"), item("two", 2, "idle needle two")]); + const parses = () => + vi + .mocked(searchableMessageSegments) + .mock.calls.filter(([message]) => message.text.startsWith("idle needle")).length; + const search = (query: string) => projection.searchThread({ threadId, query }); + // One idle minute passes inside this search. The later counts fail if it let the caches go. + yield* projection.searchThreadStream({ threadId, query: "needle" }).pipe( + Stream.tap((frame) => + frame.complete === false ? TestClock.adjust("1 minute") : Effect.void, + ), + Stream.runDrain, + ); + assert.equal(parses(), 2); + yield* TestClock.adjust("40 seconds"); + yield* search("idle"); + yield* TestClock.adjust("40 seconds"); + // A repeated query is answered from the completed scan and still counts as use. + const repeated = yield* projection + .searchThreadStream({ threadId, query: "needle" }) + .pipe(Stream.runCollect); + assert.equal(repeated.length, 1); + yield* TestClock.adjust("40 seconds"); + yield* search("one"); + assert.equal(parses(), 2); + yield* TestClock.adjust("1 minute"); + yield* search("needle"); + assert.equal(parses(), 4); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("invalidates cached counts when streamed text changes or a run rolls back", () => Effect.gen(function* () { const projection = yield* setup; diff --git a/apps/server/src/orchestration-v2/ThreadFind.ts b/apps/server/src/orchestration-v2/ThreadFind.ts index ead68680a21c9..c6a2c012f758c 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.ts @@ -12,6 +12,7 @@ import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; import * as Cache from "effect/Cache"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as RcRef from "effect/RcRef"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -38,6 +39,14 @@ interface FindDocument extends FindRow { readonly runId: OrchestrationV2TurnItem["runId"]; readonly count: number; } +type CompletedScans = Map< + string, + { + sequence: number; + documents: readonly FindDocument[]; + sourceThreadIds: readonly ThreadId[]; + } +>; class TextKey extends Data.Class<{ text: string; role: "user" | "assistant" | "plan"; @@ -232,26 +241,21 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( }); }); }); - // Scans visit every message; a cache smaller than the thread churns without hits. - const textCache = yield* Cache.make({ - capacity: 8192, - timeToLive: "1 minute", - lookup: (key: TextKey) => Effect.sync(() => parseText(key)), - }); - // Bounded separately because each entry can hold a large body; typing reuses the parse. - const largeTextCache = yield* Cache.make({ - capacity: 32, - timeToLive: "1 minute", - lookup: (key: TextKey) => Effect.sync(() => parseText(key)), + const makeTextCache = (capacity: number) => + Cache.make({ capacity, lookup: (key: TextKey) => Effect.sync(() => parseText(key)) }); + // Text keys are message content, so entries never go stale. Cache TTL only expires on access + // and would keep the last searched threads forever. Each search borrows this for its whole + // stream, and all of it is dropped once Find is idle. + const findCaches = yield* RcRef.make({ + acquire: Effect.all({ + // Scans visit every message; a cache smaller than the thread churns without hits. + text: makeTextCache(8192), + // Bounded separately because each entry can hold a large body; typing reuses the parse. + largeText: makeTextCache(32), + scans: Effect.sync((): CompletedScans => new Map()), + }), + idleTimeToLive: "1 minute", }); - const scans = new Map< - string, - { - sequence: number; - documents: readonly FindDocument[]; - sourceThreadIds: readonly ThreadId[]; - } - >(); const readRevision = Effect.fn("ThreadFind.readRevision")(function* ( threadIds: readonly ThreadId[], ) { @@ -275,7 +279,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( return revision[0]?.sequence ?? 0; }); const snapshot = Effect.fn("ThreadFind.snapshot")( - function* (input: OrchestrationV2SearchThreadInput) { + function* (input: OrchestrationV2SearchThreadInput, scans: CompletedScans) { const { threadId } = input; const active = yield* sql<{ cwd: string | null }>` SELECT COALESCE(json_extract(t.payload_json, '$.worktreePath'), p.workspace_root) AS cwd @@ -326,8 +330,9 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( const searchThreadStream = (input: OrchestrationV2SearchThreadInput) => Stream.unwrap( Effect.gen(function* () { + const { scans, ...textCache } = yield* RcRef.get(findCaches); // Release SQLite before decoding and parsing. Both frames describe one snapshot. - const data = yield* snapshot(input); + const data = yield* snapshot(input, scans); if (data.result) return Stream.succeed(data.result); const { payloads, cwd, sequence, cacheKey, sourceThreadIds } = data; const decode = (row: FindPayload) => @@ -357,7 +362,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( const decoded = yield* decode(row); const key = textKey(decoded.item, cwd, input.skills ?? []); const segments = yield* Cache.get( - key.text.length <= 32_768 ? textCache : largeTextCache, + key.text.length <= 32_768 ? textCache.text : textCache.largeText, key, ); const document = documentFor(decoded, countSegments(segments, input.query)); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 7e7829ba947cf..6397bfdb3bc72 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -361,6 +361,7 @@ const make = Effect.gen(function* () { attachments: message.attachments, ...(message.context ? { context: message.context } : {}), modelSelection, + projectId: input.projectId, }) .pipe( Effect.map((result) => ({ diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 6c80def39059f..395e8768996e8 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -175,6 +175,7 @@ const make = Effect.gen(function* () { ...(inherited === undefined ? {} : { forkSource: inherited }), instructions: settings.threadTitleInstructions, modelSelection: settings.textGenerationModelSelection, + projectId: projection.thread.projectId, }); const generatedTitle = result.title.trim(); return generatedTitle === "New thread" || diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index 09f285954992b..ab16197ba05b7 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -469,14 +469,14 @@ it.live("the agent asks without waiting, and the user links the box after its tu // The call returns at once: no tool call outlives a provider's timeout. expect(asked.isError).toBe(false); expect(asked.structuredContent).toEqual({ status: "pending", turnItemId: card.id }); - // The card names the box, found by asking its address. expect(card).toMatchObject({ url: b.url, reason: "Run the suite on the box.", - environmentId: box.environmentId, - label: "Box", requestedAccess: "approval-required", }); + // Anything can answer at an address as the box, so the card names no machine. + expect(card.environmentId).toBeUndefined(); + expect(card.label).toBeUndefined(); // The agent waits on the user, so the thread asks for input. expect((yield* a.orchestrator.getThreadShell(threadId))?.pendingRuntimeRequest).toMatchObject( { kind: "user_input" }, @@ -675,7 +675,7 @@ it.live("a declined request links nothing and tells the agent", () => const { messages } = yield* outcomeMessages(a); expect(messages).toHaveLength(1); expect(messages[0]).toMatchObject({ - notification: { outcome: "cancelled", summary: "Declined to link Box" }, + notification: { outcome: "cancelled", summary: `Declined to link ${b.url}` }, }); expect(messages[0]!.text).toContain("declined"); }), @@ -904,6 +904,45 @@ it.live("a code the box refuses fails the request with its reason, and links not ), ); +it.live("a card an older server left naming a machine beside its address links no machine", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + yield* a.orchestrator.dispatch({ + type: "link_request.record", + commandId: CommandId.make("command:pending:older"), + threadId, + runId, + nodeId: rootNode, + turnItemId: TurnItemId.make("turn-item:link-request:older"), + url: b.url, + environmentId: box.environmentId, + label: "Box", + reason: "", + linkStatus: "pending", + }); + const card = yield* pendingCard(a); + // An older client takes the machine from the card and mints a code on it. + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.requests.answer({ + threadId, + turnItemId: card.id, + answer: { + type: "link", + environmentId: box.environmentId, + label: "Box", + urls: [b.url], + access: "auto", + pairingCode: pairing.credential, + }, + }); + expect(yield* cardOf(a)).toMatchObject({ linkStatus: "failed" }); + expect(Option.isNone(yield* a.links.get(box.environmentId))).toBe(true); + expect(yield* b.linkedSessions).toEqual([]); + }), + ), +); + it.live("an agent below full access may not ask, and no card is shown", () => withBoth((a, b) => Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinkRequests.ts b/apps/server/src/peer/PeerLinkRequests.ts index 390589a81b4e4..3d1129a599f84 100644 --- a/apps/server/src/peer/PeerLinkRequests.ts +++ b/apps/server/src/peer/PeerLinkRequests.ts @@ -39,7 +39,7 @@ type LinkRequestItem = Extract & { readonly runId: RunId; readonly nodeId: NodeId }; /** The notification that tells the agent how its card ended. */ @@ -129,7 +129,6 @@ const make = Effect.gen(function* () { ...(card.url === undefined ? {} : { url: card.url }), reason: card.reason, ...(card.environmentId === undefined ? {} : { environmentId: card.environmentId }), - ...(card.label === undefined ? {} : { label: card.label }), ...(card.hint === undefined ? {} : { hint: card.hint }), ...(card.requestedAccess === undefined ? {} : { requestedAccess: card.requestedAccess }), ...outcome, @@ -252,9 +251,8 @@ const make = Effect.gen(function* () { "Asking to link an environment requires an active run owned by this MCP provider session.", }); } - // Best effort: the card names the machine at an address the agent was - // given, and the client finds it by id. - const peer = input.url === undefined ? Option.none() : yield* peerLinks.describe(input.url); + // Anything can answer at an address as any machine, so a card for an + // address names none. The client names it from addresses it already has. yield* record( { id: turnItemId, @@ -263,11 +261,7 @@ const make = Effect.gen(function* () { nodeId: run.rootNodeId, ...(input.url === undefined ? {} : { url: input.url }), reason: input.reason ?? "", - ...(Option.isSome(peer) - ? { environmentId: peer.value.environmentId, label: peer.value.label } - : input.environmentId === undefined - ? {} - : { environmentId: input.environmentId }), + ...(input.environmentId === undefined ? {} : { environmentId: input.environmentId }), ...(input.hint === undefined ? {} : { hint: input.hint }), ...(input.requestedAccess === undefined ? {} @@ -357,7 +351,18 @@ const make = Effect.gen(function* () { if (card.linkStatus !== "pending") { return yield* new PeerLinkRequestError({ reason: "already_answered" }); } - const outcome = yield* answerOutcome(input.answer); + // An id beside an address is what answered there, recorded by an older + // server. Linking that machine could send its pairing code to the address. + const outcome: LinkRequestOutcome = + card.url !== undefined && + card.environmentId !== undefined && + input.answer.type === "link" && + input.answer.environmentId !== undefined + ? { + linkStatus: "failed", + failure: "This request is from before an update. Ask for the link again.", + } + : yield* answerOutcome(input.answer); // If Stop closed the card while linking, the card keeps that; the link // itself stands and Settings lists it. yield* record( @@ -416,16 +421,16 @@ const resultOf = (card: LinkRequestItem): OrchestratorMcpEnvironmentLinkResult = } }; -/** What the agent's words call the target: its name, its address, or the user's pick. */ -const linkTarget = (card: Pick) => - card.label ?? card.url ?? card.environmentId ?? "a machine"; +/** What the agent's words call the target: its address, its id, or the user's pick. */ +const linkTarget = (card: Pick) => + card.url ?? card.environmentId ?? "a machine"; /** * The notification an answer sends the agent: what the card says, and what * to do next. It carries the outcome only, never the pairing code. */ const outcomeNotice = ( - card: Pick, + card: Pick, outcome: LinkRequestOutcome, ): Omit => { const target = linkTarget(card); diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 5198fc443f768..2ff34859c8a97 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -603,10 +603,35 @@ it.effect( expectedEnvironmentId: box.environmentId, }); expect(linked).toMatchObject({ environmentId: box.environmentId, urls: [b.url] }); + // A picked machine is never linked at this machine's own loopback. + const own = yield* a.links + .link({ + url: b.url.replace("127.0.0.1", "localhost"), + pairingCode: pairing.credential, + access: "auto", + expectedEnvironmentId: box.environmentId, + }) + .pipe(Effect.flip); + expect(own.message).toContain("is this machine"); yield* probe(a); expect(yield* linkedSessions(impostor)).toEqual([]); expect(yield* Ref.get(impostor.bearers)).toEqual([]); expect((yield* linkedSessions(b)).length).toBe(1); + + // An address alone that answers as the linked box never takes over its link. + yield* Ref.set(impostor.descriptor, box); + const second = yield* impostor.auth.issuePairingCredential(); + const taken = yield* a.links + .link({ url: impostor.url, pairingCode: second.credential, access: "auto" }) + .pipe(Effect.flip); + expect(taken.message).toContain("already linked"); + expect(yield* linkedSessions(impostor)).toEqual([]); + expect(Option.getOrUndefined(yield* a.links.get(box.environmentId))?.urls).toEqual([b.url]); + // The address the link already has still renews it. + const renewal = yield* b.auth.issuePairingCredential(); + expect( + yield* a.links.link({ url: b.url, pairingCode: renewal.credential, access: "auto" }), + ).toMatchObject({ urls: [b.url] }); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts index 9fa7b74d340c6..7ba3d144ef104 100644 --- a/apps/server/src/peer/PeerLinks.testkit.ts +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -184,6 +184,8 @@ export const layerLinkingEnvironment = ( Layer.provideMerge(ServerSettings.layerTest({ linkedEnvironmentsEnabled: true })), Layer.provideMerge(database), Layer.provide(Layer.mergeAll(secrets, FetchHttpClient.layer)), + // Peers here are served on 127.0.0.1, so only `localhost` stands for this machine. + Layer.provide(Layer.succeed(PeerLinks.LoopbackHost, (host) => host === "localhost")), // Services that act for this environment, such as delegation, name it too. Layer.provideMerge( Layer.succeed(ServerEnvironment.ServerEnvironment, { diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index f52faecfcc3e2..f5796d7a6994d 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -92,15 +92,6 @@ export class PeerLinks extends Context.Service< /** Whether the Linked environments beta (Settings → Labs) is on here. */ readonly enabled: Effect.Effect; readonly link: (input: PeerLinkCreateInput) => Effect.Effect; - /** - * The environment answering at `url`, or nothing when it is not a usable - * address or does not answer as T3 Code. Sends no credential. - */ - readonly describe: ( - url: string, - ) => Effect.Effect< - Option.Option<{ readonly environmentId: EnvironmentId; readonly label: string }> - >; readonly list: Effect.Effect, PeerLinkError>; /** The stored link, without asking the peer whether it answers. */ readonly get: ( @@ -182,6 +173,12 @@ const peerOrigin = (url: string) => { return Effect.succeed(parsed.origin); }; +/** Whether a host is this machine's own loopback. Tests serve their peers there, so they narrow it. */ +export const LoopbackHost = Context.Reference<(host: string) => boolean>( + "t3/peer/PeerLinks/LoopbackHost", + { defaultValue: () => isLocalLoopbackHost }, +); + /** Whether a peer holds a linked agent to its limits and keeps its work within its link. */ const linkLimitsHold = (peer: { readonly capabilities: ExecutionEnvironmentCapabilities }) => peer.capabilities.mcpModeLimitHeader === true && peer.capabilities.linkFence === true; @@ -194,6 +191,7 @@ const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const environment = yield* ServerEnvironment.ServerEnvironment; const serverSettings = yield* ServerSettings.ServerSettingsService; + const isOwnLoopback = yield* LoopbackHost; const enabled = serverSettings.getSettings.pipe( Effect.map((settings) => settings.linkedEnvironmentsEnabled), Effect.orElseSucceed(() => false), @@ -409,6 +407,13 @@ const make = Effect.gen(function* () { (url) => Effect.gen(function* () { const origin = yield* peerOrigin(url); + // Loopback is this machine, where anything may answer as the one picked. + if (isOwnLoopback(new URL(origin).hostname)) { + return yield* new PeerLinkError({ + reason: "unreachable", + message: `${origin} is this machine, not ${name}.`, + }); + } return { origin, descriptor: yield* describe(origin) }; }).pipe(Effect.result), { concurrency: "unbounded" }, @@ -457,8 +462,8 @@ const make = Effect.gen(function* () { return yield* new PeerLinkError({ reason: "disabled", message: LINKED_ENVIRONMENTS_OFF }); } const urls = [input.url, ...(input.alternateUrls ?? [])]; - // The user picked a machine: link through the addresses that prove to - // be it, so a reused LAN address never receives the pairing code. + // The user picked a machine: link through the addresses that answer as + // it, so one that now answers as another never receives the pairing code. const { primary, peer, origins } = input.expectedEnvironmentId === undefined ? yield* answeringAt(urls) @@ -467,6 +472,22 @@ const make = Effect.gen(function* () { input.expectedEnvironmentId, input.expectedLabel ?? input.expectedEnvironmentId, ); + // Anything can answer as a linked machine, so an address alone renews its + // link only where the link already reaches it, and never moves it. + if (input.expectedEnvironmentId === undefined) { + const row = yield* readRow(peer.environmentId); + const existing = Option.isSome(row) ? yield* toLink(row.value) : undefined; + if ( + existing !== undefined && + DateTime.isGreaterThan(existing.expiresAt, yield* DateTime.now) && + !existing.urls.includes(primary) + ) { + return yield* new PeerLinkError({ + reason: "pairing_rejected", + message: `${primary} answers as ${existing.label}, which is already linked at another address. If ${existing.label} moved, forget that link first.`, + }); + } + } const self = yield* environment.getDescriptor; if (peer.environmentId === self.environmentId) { return yield* new PeerLinkError({ @@ -532,13 +553,6 @@ const make = Effect.gen(function* () { return linked; }); - const describeUrl: PeerLinks["Service"]["describe"] = (url) => - peerOrigin(url).pipe( - Effect.flatMap(describe), - Effect.map(Option.map((peer) => ({ environmentId: peer.environmentId, label: peer.label }))), - Effect.orElseSucceed(() => Option.none()), - ); - /** A peer's label follows its machine name, so the link takes the one its own address answers with. */ const withLabel = (link: PeerLink, label: string) => label === link.label @@ -621,8 +635,8 @@ const make = Effect.gen(function* () { message: `The link to ${link.label} lost its credential. Link it again.`, }); } - // The token goes only to an address that proves it is this peer, so a - // reused LAN address or a moved hostname never receives it. + // The token goes only to an address that still answers as this peer, + // so one that now answers as another machine never receives it. let fenced = true; let label = link.label; const url = yield* Effect.findFirst(link.urls, (candidate) => @@ -678,7 +692,6 @@ const make = Effect.gen(function* () { return PeerLinks.of({ enabled, link, - describe: describeUrl, list, get, unlink, diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 78f03f1f91b72..855d30f80ee0d 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -180,7 +180,6 @@ describe("server browser element refs", () => { ); expect(result.visibleText).not.toContain("editable-pass"); expect(result.visibleText).toContain("Order ••••••••-2"); - expect(tree).toMatch(/row "password: •+"/); expect(tree).toMatch(/textbox "Username" [^\n]*: maria$/m); const line = tree.split("\n").find((line) => line.includes('textbox "Password"')); expect(line).toMatch(/: •+$/); diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index f9c7aaf3fd3d4..6ab04f7e302a4 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; +import * as NodeSqlite from "node:sqlite"; import { describe, expect, it } from "@effect/vitest"; import { AgentSessionImportSource, @@ -1087,6 +1088,38 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ]); }), ); + + it.effect("keeps Codex rollout cwds when the Codex state database is unreadable", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "05", + "03", + "rollout-2026-05-03T00-00-00-019a5f00-0000-7000-8000-00000000000a.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + yield* fileSystem.writeFileString( + path.join(codexHomePath, "state_5.sqlite"), + "not a database", + ); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map(({ path, threadCount }) => ({ path, threadCount }))).toEqual([ + { path: workspace, threadCount: 1 }, + ]); + }), + ); }); describe("recentThreads", () => { @@ -1345,6 +1378,140 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("imports Codex chats moved from a scratch folder into a Codex project", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowMs = Date.parse("2026-11-08T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const homeDir = yield* makeTempDir("t3code-home-"); + const projectDir = path.join(homeDir, "code", "my-project"); + const scratchDir = path.join(homeDir, "Documents", "Codex", "2026-11-08", "new-chat"); + const otherDir = path.join(homeDir, "code", "other-project"); + yield* fileSystem.makeDirectory(projectDir, { recursive: true }); + yield* fileSystem.makeDirectory(scratchDir, { recursive: true }); + yield* fileSystem.makeDirectory(otherDir, { recursive: true }); + + const projectId = "019a5f00-0000-7000-8000-000000000001"; + const chats = [ + { + id: "019a5f00-0000-7000-8000-00000000000a", + cwd: scratchDir, + message: "Started with no project", + }, + { + id: "019a5f00-0000-7000-8000-00000000000b", + cwd: projectDir, + message: "Started in the project", + }, + { + id: "019a5f00-0000-7000-8000-00000000000c", + cwd: otherDir, + message: "Started in another folder", + }, + { + id: "019a5f00-0000-7000-8000-00000000000d", + rolloutId: "019a5f00-0000-7000-8000-0000000000d1", + cwd: scratchDir, + message: "Rewound with no project", + }, + ]; + const rolloutPaths: Array = []; + for (const [index, chat] of chats.entries()) { + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "11", + "08", + `rollout-2026-11-08T08-0${index}-00-${"rolloutId" in chat ? `${chat.id}_${chat.rolloutId}` : chat.id}.jsonl`, + ); + rolloutPaths.push(filePath); + yield* writeTranscript({ + filePath, + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: chat.id, cwd: chat.cwd }, + }), + encodeTranscriptRecord({ type: "turn_context", payload: { cwd: chat.cwd } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: chat.message }, + }), + ].join("\n"), + mtimeMs: nowMs - 60_000 + index, + }); + } + // Codex records a chat's project only in its state DB; the rollout keeps the original cwd. + yield* Effect.sync(() => { + const db = new NodeSqlite.DatabaseSync(path.join(codexHomePath, "state_5.sqlite")); + try { + db.exec(` + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + position INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + CREATE TABLE project_roots ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + path TEXT NOT NULL, + PRIMARY KEY (project_id, position) + ); + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + cwd TEXT NOT NULL, + project_id TEXT REFERENCES projects(id) ON DELETE SET NULL + ); + `); + db.prepare("INSERT INTO projects VALUES (?, ?, '{}', 0, ?, ?)").run( + projectId, + "my-project", + nowMs, + nowMs, + ); + db.prepare("INSERT INTO project_roots VALUES (?, 0, ?)").run(projectId, projectDir); + const insertThread = db.prepare("INSERT INTO threads VALUES (?, ?, ?, ?)"); + for (const [index, chat] of chats.entries()) { + insertThread.run(chat.id, rolloutPaths[index]!, chat.cwd, projectId); + } + } finally { + db.close(); + } + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: projectDir, + }).pipe(Effect.provideService(HostProcess.HomeDirectory, homeDir)); + expect(threads.map((thread) => thread.title).toSorted()).toEqual([ + "Rewound with no project", + "Started in the project", + "Started with no project", + ]); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(HostProcess.HomeDirectory, homeDir), + ); + expect( + result.candidates + .map(({ path, threadCount }) => ({ path, threadCount })) + .toSorted((left, right) => left.path.localeCompare(right.path)), + ).toEqual([ + { path: projectDir, threadCount: 3 }, + { path: otherDir, threadCount: 1 }, + ]); + }), + ); + it.effect("imports a rewound Codex thread with the history its new rollout inherits", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index dc67bd9387e04..1b32de22a8878 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -1,4 +1,5 @@ import * as NodeOS from "node:os"; +import * as NodeSqlite from "node:sqlite"; import { AgentSessionScanError, @@ -55,9 +56,19 @@ class TranscriptParseError extends Schema.TaggedError()( return `Could not parse the transcript at ${this.filePath}.`; } } + +class CodexStateReadError extends Schema.TaggedError()("CodexStateReadError", { + databasePath: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + return `Could not read Codex projects from ${this.databasePath}.`; + } +} const METADATA_READ_BYTES = 8 * 1024; const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; const MAX_CODEX_SESSION_INDEX_BYTES = 1024 * 1024; +const MAX_CODEX_PROJECT_ROOT_ROWS = 100_000; const MAX_TRANSCRIPTS_PER_SOURCE = 5000; @@ -159,7 +170,9 @@ const decodeCodexHistoryBase = Schema.decodeUnknownOption( ); // Original rollouts end in their thread ID, rewound ones in `_`. const CODEX_ROLLOUT_ID_PATTERN = - /[-_]([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; + /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))?\.jsonl$/i; +// Codex records a chat's project only here; moving a chat leaves its rollout cwd unchanged. +const CODEX_STATE_DATABASE = "state_5.sqlite"; const decodeCodexSessionIndexEntry = Schema.decodeUnknownOption( Schema.fromJsonString(CodexSessionIndexEntry), ); @@ -224,6 +237,7 @@ interface RawCandidate { readonly transcripts: ReadonlyArray<{ readonly filePath: string; readonly mtimeMs: number | null; + readonly projectRoots?: ReadonlyArray | undefined; }>; } @@ -233,6 +247,7 @@ interface TranscriptCandidate { readonly providerInstanceId: ProviderInstanceId; readonly homePath: string; readonly size: number; + readonly projectRoots?: ReadonlyArray | undefined; } interface MetadataReadBudget { @@ -1234,6 +1249,47 @@ export const make = Effect.gen(function* () { }, ); + const readCodexProjectRoots = Effect.fn("AgentSessionScanner.readCodexProjectRoots")(function* ( + homePath: string, + ) { + const rootsByThread = new Map>(); + const databasePath = path.join(homePath, CODEX_STATE_DATABASE); + return yield* Effect.try({ + try: () => { + const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + try { + database.exec("PRAGMA busy_timeout = 100"); + const rows = database + .prepare( + `SELECT threads.id AS id, project_roots.path AS path + FROM threads JOIN project_roots ON project_roots.project_id = threads.project_id + ORDER BY threads.rowid DESC, project_roots.position LIMIT ?`, + ) + .all(MAX_CODEX_PROJECT_ROOT_ROWS); + for (const { id, path: root } of rows) { + if (typeof id !== "string" || typeof root !== "string" || root.trim().length === 0) { + continue; + } + const roots = rootsByThread.get(id); + if (roots === undefined) rootsByThread.set(id, [root]); + else roots.push(root); + } + return rootsByThread; + } finally { + database.close(); + } + }, + catch: (cause) => new CodexStateReadError({ databasePath, cause }), + }).pipe(Effect.orElseSucceed((): ReadonlyMap> => new Map())); + }); + + // Only chats left in an excluded folder, such as Codex's scratch folder, take their project's root. + const projectCwd = (cwd: string, projectRoots: ReadonlyArray | undefined) => + projectRoots !== undefined && + isExcludedProjectPath(path.resolve(expandHomePath(cwd.trim(), homeDir))) + ? projectRoots[0]! + : cwd; + const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")( function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { const sessionsDir = path.join(homePath, "sessions"); @@ -1295,7 +1351,15 @@ export const make = Effect.gen(function* () { } } } - return { transcripts, truncated }; + const projectRoots = yield* readCodexProjectRoots(homePath); + return { + transcripts: transcripts.map((transcript) => { + const threadId = CODEX_ROLLOUT_ID_PATTERN.exec(transcript.filePath)?.[1]; + const roots = threadId === undefined ? undefined : projectRoots.get(threadId); + return roots === undefined ? transcript : { ...transcript, projectRoots: roots }; + }), + truncated, + }; }, ); @@ -1365,13 +1429,14 @@ export const make = Effect.gen(function* () { providerInstanceId: ProviderInstanceId; homePath: string; lastActiveAtMs: number; - transcripts: Array<{ filePath: string; mtimeMs: number }>; + transcripts: Array; } >(); for (const transcript of transcripts) { - const cwd = yield* readCwd(source, transcript, budget); - if (cwd === null || cwd === false) continue; + const rolloutCwd = yield* readCwd(source, transcript, budget); + if (rolloutCwd === null || rolloutCwd === false) continue; + const cwd = projectCwd(rolloutCwd, transcript.projectRoots); const key = `${transcript.providerInstanceId}\0${cwd}`; const existing = byOwnerAndCwd.get(key); if (existing) { @@ -1718,7 +1783,8 @@ export const make = Effect.gen(function* () { candidates.flatMap((candidate) => candidate.source === "codex" && candidate.providerInstanceId === providerInstanceId ? candidate.transcripts.flatMap((transcript) => { - const id = CODEX_ROLLOUT_ID_PATTERN.exec(transcript.filePath)?.[1]; + const match = CODEX_ROLLOUT_ID_PATTERN.exec(transcript.filePath); + const id = match?.[2] ?? match?.[1]; return id === undefined ? [] : [[id, transcript.filePath] as const]; }) : [], @@ -1804,7 +1870,10 @@ export const make = Effect.gen(function* () { if (Option.isNone(snapshot.cwd)) { return Option.some({ _tag: "Skipped" }); } - const expandedCwd = expandHomePath(snapshot.cwd.value.trim(), homeDir); + const expandedCwd = expandHomePath( + projectCwd(snapshot.cwd.value, transcript.projectRoots).trim(), + homeDir, + ); if ( !path.isAbsolute(expandedCwd) || (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c2d4b63861074..3ada12297649d 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -309,9 +309,15 @@ export const ClaudeDriver: ProviderDriver = Effect.fn( "ClaudeDriver.consumeResetCredit", )( - function* () { + function* (creditId) { const current = yield* snapshot.getSnapshot; - const grantId = current.usageLimits?.resetCredits?.nextCreditId; + const credits = current.usageLimits?.resetCredits; + const grantId = + creditId === undefined + ? credits?.nextCreditId + : credits?.options?.some((option) => option.creditId === creditId) + ? creditId + : undefined; if (!grantId || !current.version) return "noCredit" as const; const version = current.version; return yield* resetCreditCoordinator.redeem( diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index e998332d3e92f..95866780189ac 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -664,7 +664,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { const before = yield* instance!.snapshot.refresh; expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); - const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); + const outcome = yield* instance!.consumeResetCredit!(undefined).pipe(Effect.result); return { outcome, after: yield* instance!.snapshot.getSnapshot }; }).pipe( // macOS logins live in the Keychain, where resets are never read. diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index da21333dad95e..ede0e5c47d20e 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -15,6 +15,10 @@ import { makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; +export function antigravityUserAgent(platform: NodeJS.Platform, architecture: NodeJS.Architecture) { + return `antigravity/hub/2.24.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`; +} + const DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com"; const PROD_ENDPOINT = "https://cloudcode-pa.googleapis.com"; // The ACP runtime meters consumer tiers on the daily host and GCP-terms tiers on prod. @@ -120,6 +124,22 @@ function summaryResetTime( return untouched ? undefined : bucket.resetTime; } +const sha256 = Effect.fnUntraced(function* (text: string) { + const digest = yield* (yield* Digest.Digest).digest( + Digest.sha256, + new TextEncoder().encode(text), + ); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +}); + +/** Keys an account by its Google ID, which matches across machines and hubs; refresh tokens do not. */ +export const antigravityAccountFingerprint = (userInfo: unknown) => + decodeUserInfo(userInfo).pipe(Effect.flatMap((info) => sha256(`google-account\0${info.id}`))); + +/** Windows from a `retrieveUserQuotaSummary` answer requested at `requestedAt`. */ +export const antigravitySummaryWindows = (response: unknown, requestedAt: number) => + decodeSummary(response).pipe(Effect.map((summary) => summaryWindows(summary, requestedAt))); + function summaryWindows(summary: typeof Summary.Type, requestedAt: number) { const windows = new Map(); const groups = summary.groups?.some((group) => group.buckets?.length) @@ -203,15 +223,6 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( | undefined; let generation = 0; - const sha256 = (text: string) => - crypto - .digest(Digest.sha256, new TextEncoder().encode(text)) - .pipe( - Effect.map((digest) => - Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""), - ), - ); - const request = (request: HttpClientRequest.HttpClientRequest) => client.execute(request).pipe( Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT), @@ -259,8 +270,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}` }), ), ).pipe( - Effect.flatMap(decodeUserInfo), - Effect.flatMap((info) => sha256(`google-account\0${info.id}`)), + Effect.flatMap(antigravityAccountFingerprint), Effect.orElseSucceed(() => fingerprint), ); if (generation === startedGeneration) { @@ -294,7 +304,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( HttpClientRequest.post(`${endpoint}/v1internal:${operation}`).pipe( HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}`, - "User-Agent": `antigravity/hub/2.24.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`, + "User-Agent": antigravityUserAgent(platform, architecture), }), HttpClientRequest.bodyJsonUnsafe( token.project_id ? { project: token.project_id } : {}, @@ -313,9 +323,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( } return [...windows.values()]; }); - const summary = yield* fetchWindows("retrieveUserQuotaSummary", (response, requestedAt) => - decodeSummary(response).pipe(Effect.map((summary) => summaryWindows(summary, requestedAt))), - ); + const summary = yield* fetchWindows("retrieveUserQuotaSummary", antigravitySummaryWindows); const windows = summary.length ? summary : yield* fetchWindows("fetchAvailableModels", (response) => @@ -331,6 +339,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: account }; }); const probe = read.pipe( + Effect.provideService(Digest.Digest, crypto), Effect.timeout("30 seconds"), Effect.catch(() => Effect.gen(function* () { diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 8db751657454c..a3a1110d87d34 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -62,9 +62,51 @@ describe("claudeResetCreditsToContract", () => { availableCount: 2, nextCreditId: "grant_a", nextExpiresAt: "2026-10-01T00:00:00.000Z", + options: [ + expect.objectContaining({ + creditId: "grant_a", + label: "Full reset", + availableCount: 2, + expiresAt: "2026-10-01T00:00:00.000Z", + }), + ], }); }); + it("offers each kind of reset by its own grant", () => { + const credits = ClaudeResetCredits.claudeResetCreditsToContract( + { + eligible: true, + next_grant_id: "weekly_a", + grants: [ + grant({ id: "weekly_a", clears: ["seven_day"], ends_at: "2026-12-10T22:00:00Z" }), + grant({ + id: "fable_a", + clears: ["seven_day_overage_included"], + gives_back_weekly: true, + ends_at: "2027-03-09T07:59:00Z", + }), + grant({ id: "fable_b", gives_back_weekly: true, type: "weekly" }), + grant({ id: "session_a", type: "session" }), + grant({ id: "spent", clears: ["seven_day"], resets_left: 0 }), + ], + }, + NOW, + ); + expect( + credits?.options?.map(({ creditId, label, availableCount }) => [ + creditId, + label, + availableCount, + ]), + ).toEqual([ + ["session_a", "5-hour reset", 1], + ["weekly_a", "Weekly reset", 1], + ["fable_a", "Fable reset", 2], + ]); + expect(credits).toMatchObject({ availableCount: 4, nextCreditId: "weekly_a" }); + }); + it("offers nothing to redeem without a usable next grant or an eligible account", () => { expect( ClaudeResetCredits.claudeResetCreditsToContract( @@ -111,7 +153,7 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, client), ); - expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); + expect(credits).toMatchObject({ availableCount: 1, nextCreditId: "grant_a" }); }), ); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 35973378edada..e1dbacc34ba71 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -1,5 +1,6 @@ import type { ProviderConsumeResetCreditOutcome, + ServerProviderResetCreditOption, ServerProviderResetCredits, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -31,6 +32,9 @@ const Grant = Schema.Struct({ ends_at: Schema.optional(Schema.NullOr(Schema.String)), paused: Schema.optional(Schema.Boolean), usable_now: Schema.optional(Schema.Boolean), + clears: Schema.optional(Schema.Array(Schema.Unknown)), + type: Schema.optional(Schema.Unknown), + gives_back_weekly: Schema.optional(Schema.Boolean), }); const decodeGrant = Schema.decodeUnknownOption(Grant); const CedarEmber = Schema.Struct({ @@ -95,6 +99,31 @@ const isFutureTimestamp = (value: string, nowMs: number) => { ); }; +// Claude Code's own grouping: a grant that gives back weekly usage is a Fable reset, else its +// declared type, else what it clears. +const RESET_KINDS = { + session: { label: "5-hour reset", description: "Refills your 5-hour limit." }, + weekly: { label: "Weekly reset", description: "Refills your weekly limit." }, + fable: { + label: "Fable reset", + description: + "Refills your Fable allowance and gives back what Fable used on your weekly limit. It doesn't reset your whole week.", + }, + full: { label: "Full reset", description: "Refills your 5-hour and weekly limits." }, +} as const; +type ResetKind = keyof typeof RESET_KINDS; + +const grantKind = (grant: typeof Grant.Type): ResetKind => { + if (grant.gives_back_weekly) return "fable"; + if (grant.type === "session" || grant.type === "weekly" || grant.type === "full") { + return grant.type; + } + const clears = grant.clears ?? []; + const session = clears.includes("five_hour"); + const other = clears.some((limit) => limit !== "five_hour"); + return session && !other ? "session" : other && !session ? "weekly" : "full"; +}; + export function claudeResetCreditsToContract( block: unknown, nowMs: number, @@ -110,13 +139,38 @@ export function claudeResetCreditsToContract( (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), ); const next = live.find((grant) => grant.id === parsed.value.next_grant_id); - const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); + const expiresAt = (grant: typeof Grant.Type | undefined) => { + const at = grant?.ends_at ? DateTime.make(grant.ends_at) : Option.none(); + return Option.isSome(at) ? DateTime.formatIso(at.value) : undefined; + }; + const byKind = new Map>(); + for (const grant of live) { + if (grant.resets_left === 0) continue; + const kind = grantKind(grant); + byKind.set(kind, [...(byKind.get(kind) ?? []), grant]); + } + const options = (Object.keys(RESET_KINDS) as Array).flatMap( + (kind): Array => { + const grants = byKind.get(kind); + const target = grants?.[0]; + if (!grants || !target) return []; + const optionExpiresAt = expiresAt(target); + return [ + { + creditId: target.id, + ...RESET_KINDS[kind], + availableCount: grants.reduce((sum, grant) => sum + grant.resets_left, 0), + ...(optionExpiresAt ? { expiresAt: optionExpiresAt } : {}), + }, + ]; + }, + ); + const nextExpiresAt = expiresAt(next); return { availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, - ...(Option.isSome(nextExpiresAt) - ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } - : {}), + ...(nextExpiresAt ? { nextExpiresAt } : {}), ...(next ? { nextCreditId: next.id } : {}), + ...(next ? { options } : {}), }; } diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index 8994837fcdd80..450d42ebc3cb1 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -403,6 +403,7 @@ const make = Effect.gen(function* () { ...reviewGuideInputFromPullRequest(detail, activity, checkout.baseCommit), cwd: checkout.path, modelSelection, + projectId: input.projectId, instructions: projectSettings.reviewGuideInstructions, stack: checkout.stack, }), diff --git a/apps/server/src/speech/SpeechModelStore.test.ts b/apps/server/src/speech/SpeechModelStore.test.ts index e08a4cf022f4a..be25e4f290b17 100644 --- a/apps/server/src/speech/SpeechModelStore.test.ts +++ b/apps/server/src/speech/SpeechModelStore.test.ts @@ -12,6 +12,8 @@ import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as ServerConfig from "../config.ts"; import * as SpeechModelStore from "./SpeechModelStore.ts"; @@ -168,3 +170,53 @@ it.effect("cleans only download partials on cancellation or restart", () => ]); }).pipe(Effect.provide(dependencies)), ); + +it.effect("links a verified model file from another folder and never deletes it", () => + Effect.gen(function* () { + const h = yield* harness; + const path = yield* Path.Path; + const elsewhere = yield* h.fs.makeTempDirectoryScoped({ prefix: "speech-other-app-" }); + const nested = path.join(elsewhere, "hub", "snapshots"); + yield* h.fs.makeDirectory(nested, { recursive: true }); + const impostor = path.join(elsewhere, "same-size.gguf"); + const existing = path.join(nested, "renamed-by-the-other-app.gguf"); + yield* h.fs.writeFile(impostor, new Uint8Array(bytes.length)); + yield* h.fs.writeFile(existing, bytes); + + expect(yield* h.store.link([h.model], impostor)).toEqual([]); + expect(yield* h.store.linkedFrom(h.model)).toBeUndefined(); + + expect(yield* h.store.link([h.model], elsewhere)).toEqual([h.model.id]); + expect(yield* h.store.linkedFrom(h.model)).toBe(existing); + expect(yield* h.store.installedPath(h.model)).toBe(h.stale); + expect(h.requests).toHaveLength(0); + // Pointing at T3 Code's own folder must not turn the link into a link to itself. + expect(yield* h.store.link([h.model], h.directory)).toEqual([]); + expect(yield* h.store.installedPath(h.model)).toBe(h.stale); + + yield* h.store.remove(h.model); + expect(yield* h.store.isReady(h.model)).toBe(false); + expect([...(yield* h.fs.readFile(existing))]).toEqual([...bytes]); + }).pipe(Effect.scoped, Effect.provide(dependencies)), +); + +it.effect("installs from a Hugging Face cache copy instead of downloading", () => + Effect.gen(function* () { + const h = yield* harness; + const path = yield* Path.Path; + const home = yield* h.fs.makeTempDirectoryScoped({ prefix: "speech-home-" }); + const snapshot = path.join( + home, + ".cache/huggingface/hub/models--fixture--speech-model/snapshots", + h.model.revision, + ); + yield* h.fs.makeDirectory(snapshot, { recursive: true }); + const cached = path.join(snapshot, h.model.filename); + yield* h.fs.writeFile(cached, bytes); + const store = yield* h.openStore.pipe(Effect.provideService(HostProcess.HomeDirectory, home)); + + expect(yield* store.download(h.model)).toBe(h.stale); + expect(yield* store.linkedFrom(h.model)).toBe(cached); + expect(h.requests).toHaveLength(0); + }).pipe(Effect.scoped, Effect.provide(dependencies)), +); diff --git a/apps/server/src/speech/SpeechModelStore.ts b/apps/server/src/speech/SpeechModelStore.ts index 99f3fe6ca5bbc..d12999194df21 100644 --- a/apps/server/src/speech/SpeechModelStore.ts +++ b/apps/server/src/speech/SpeechModelStore.ts @@ -14,7 +14,10 @@ import type { PlatformError } from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as ServerConfig from "../config.ts"; +import { expandHomePath } from "../os-jank.ts"; import type { SpeechModel } from "./model.ts"; type ModelFile = Pick; @@ -44,6 +47,7 @@ export class SpeechModelStore extends Context.Service< readonly isReady: (model: ModelFile) => Effect.Effect; /** The installed file once its SHA-256 has been verified, hashing it on first use. */ readonly installedPath: (model: ModelFile) => Effect.Effect; + /** Links a copy the Hugging Face cache or Handy already holds; fetches only without one. */ readonly download: ( model: ModelFile, progress?: SpeechModelDownloadProgress, @@ -51,6 +55,17 @@ export class SpeechModelStore extends Context.Service< string, SpeechModelDownloadError | PlatformError | HttpClientError.HttpClientError >; + /** + * Installs catalog files already on this machine as symlinks, never copies. + * `source` is a file or a folder to search; returns the ids it installed. + */ + readonly link: ( + models: ReadonlyArray, + source: string, + ) => Effect.Effect, PlatformError>; + /** The outside file a linked model points at. */ + readonly linkedFrom: (model: ModelFile) => Effect.Effect; + /** Removes T3 Code's file or link; a linked file itself is left in place. */ readonly remove: (model: ModelFile) => Effect.Effect; } >()("t3/speech/SpeechModelStore") {} @@ -59,9 +74,14 @@ const DOWNLOAD_ATTEMPTS = 3; const PARTIAL_FILENAME = /\.gguf\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.part$/i; +const LINK_SEARCH_DEPTH = 5; +const LINK_SEARCH_ENTRIES = 5_000; + const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const platform = yield* HostProcess.Platform; const http = yield* HttpClient.HttpClient; const random = yield* SecureRandom.SecureRandom; const config = yield* ServerConfig.ServerConfig; @@ -73,6 +93,28 @@ const make = Effect.gen(function* () { } } const modelPath = (model: ModelFile) => path.join(directory, model.filename); + const appDataDirectories = + platform === "darwin" + ? [path.join(home, "Library", "Application Support")] + : platform === "win32" + ? [path.join(home, "AppData", "Roaming")] + : [path.join(home, ".local", "share"), path.join(home, ".config")]; + // Where the Hugging Face cache and Handy keep these same pinned files. + const knownCopies = (model: ModelFile) => [ + path.join( + home, + ".cache", + "huggingface", + "hub", + `models--${model.id.replace("/", "--")}`, + "snapshots", + model.revision, + model.filename, + ), + ...appDataDirectories.map((appData) => + path.join(appData, "com.pais.handy", "models", model.filename), + ), + ]; // Files whose digest failed, by identity, so status polls stop calling them ready. const corrupt = new Map(); @@ -179,12 +221,81 @@ const make = Effect.gen(function* () { return downloaded; }); + const filesUnder = Effect.fn("SpeechModelStore.filesUnder")(function* (source: string) { + const files: Array<{ readonly file: string; readonly size: number }> = []; + const pending = [{ entry: source, depth: LINK_SEARCH_DEPTH }]; + for (let visited = 0; pending.length > 0 && visited < LINK_SEARCH_ENTRIES; visited += 1) { + const { entry, depth } = pending.pop()!; + const info = yield* statFile(entry); + if (info?.type === "File") files.push({ file: entry, size: Number(info.size) }); + if (info?.type !== "Directory" || depth === 0) continue; + for (const name of yield* fs.readDirectory(entry).pipe(Effect.orElseSucceed(() => []))) + pending.push({ entry: path.join(entry, name), depth: depth - 1 }); + } + return files; + }); + + const realPath = (file: string) => fs.realPath(file).pipe(Effect.orElseSucceed(() => undefined)); + + const link: SpeechModelStore["Service"]["link"] = Effect.fn("SpeechModelStore.link")( + function* (models, source) { + const root = path.resolve( + yield* expandHomePath(source.trim()).pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcess.HomeDirectory, home), + ), + ); + yield* fs.makeDirectory(directory, { recursive: true }); + const linked: string[] = []; + for (const { file, size } of yield* filesUnder(root)) { + for (const model of models) { + if (model.size !== size || linked.includes(model.id)) continue; + const finalPath = modelPath(model); + // Pointing at T3 Code's own model folder must not replace a file with a link to itself. + if ((yield* realPath(file)) === (yield* realPath(finalPath))) continue; + if (yield* verifiedPath(model, finalPath)) continue; + const hashed = yield* expectedFile(model, file); + if (!hashed || !(yield* digestMatches(model, file))) continue; + // Staged then renamed, so a failed link never removes the file already installed. + const staged = `${finalPath}.${yield* random.uuidV4()}.part`; + // Windows grants symlinks only to privileged accounts; a hard link also shares the bytes. + const hardLinked = yield* fs.symlink(file, staged).pipe( + Effect.as(false), + Effect.catch(() => fs.link(file, staged).pipe(Effect.as(true))), + ); + const current = yield* expectedFile(model, staged); + const marker = identity(model, hashed); + if (!current || identity(model, current) !== marker) { + yield* fs.remove(staged, { force: true }); + continue; + } + yield* fs.rename(staged, finalPath); + // A hard link cannot be read back, so its origin is recorded beside it. + yield* ( + hardLinked + ? fs.writeFileString(`${finalPath}.source`, file, { mode: 0o600 }) + : fs.remove(`${finalPath}.source`, { force: true }) + ).pipe(Effect.ignore); + yield* fs + .writeFileString(`${finalPath}.verified`, marker, { mode: 0o600 }) + .pipe(Effect.ignore); + linked.push(model.id); + } + } + return linked; + }, + ); + const download: SpeechModelStore["Service"]["download"] = Effect.fn("SpeechModelStore.download")( function* (model, progress = {}) { yield* Effect.annotateCurrentSpan({ modelId: model.id }); const finalPath = modelPath(model); yield* fs.makeDirectory(directory, { recursive: true }); if (yield* verifiedPath(model, finalPath, progress.onVerificationStart)) return finalPath; + for (const copy of knownCopies(model)) { + const linked = yield* link([model], copy).pipe(Effect.orElseSucceed(() => [])); + if (linked.length > 0) return finalPath; + } const partialPath = `${finalPath}.${yield* random.uuidV4()}.part`; const url = `https://huggingface.co/${model.id}/resolve/${model.revision}/${model.filename}`; return yield* Effect.gen(function* () { @@ -211,6 +322,7 @@ const make = Effect.gen(function* () { if (!complete || !(yield* digestMatches(model, partialPath))) return yield* new SpeechModelDownloadError({ modelId: model.id, reason: "verification" }); yield* fs.rename(partialPath, finalPath); + yield* fs.remove(`${finalPath}.source`, { force: true }).pipe(Effect.ignore); // Rename changes file metadata, so record the verified file's final identity. const info = yield* fs.stat(finalPath); yield* fs @@ -228,6 +340,13 @@ const make = Effect.gen(function* () { ); return SpeechModelStore.of({ + link, + linkedFrom: Effect.fn("SpeechModelStore.linkedFrom")((model) => + fs.readLink(modelPath(model)).pipe( + Effect.catch(() => fs.readFileString(`${modelPath(model)}.source`)), + Effect.orElseSucceed(() => undefined), + ), + ), isReady: Effect.fn("SpeechModelStore.isReady")((model) => expectedFile(model, modelPath(model)).pipe( Effect.map( @@ -243,6 +362,7 @@ const make = Effect.gen(function* () { const file = modelPath(model); yield* fs.remove(file, { force: true }); yield* fs.remove(`${file}.verified`, { force: true }); + yield* fs.remove(`${file}.source`, { force: true }); }), }); }); diff --git a/apps/server/src/speech/SpeechService.test.ts b/apps/server/src/speech/SpeechService.test.ts index 41db47f0376a4..8d980f2846271 100644 --- a/apps/server/src/speech/SpeechService.test.ts +++ b/apps/server/src/speech/SpeechService.test.ts @@ -103,6 +103,8 @@ const store = Layer.succeed( installedPath: (model) => Effect.sync(() => (readyModels.has(model.id) ? "test.gguf" : undefined)), download, + link: () => Effect.succeed([]), + linkedFrom: () => Effect.undefined, remove: (model) => Effect.sync(() => { readyModels.delete(model.id); diff --git a/apps/server/src/speech/SpeechService.ts b/apps/server/src/speech/SpeechService.ts index c5f0faebe18f4..b4ae198b9447d 100644 --- a/apps/server/src/speech/SpeechService.ts +++ b/apps/server/src/speech/SpeechService.ts @@ -138,6 +138,8 @@ export class SpeechService extends Context.Service< readonly selectModel: (modelId: string) => Effect.Effect; readonly cancelDownload: (modelId: string) => Effect.Effect; readonly removeModel: (modelId: string) => Effect.Effect; + /** Uses catalog model files already on this machine, from a file or folder path. */ + readonly linkModels: (source: string) => Effect.Effect; readonly prepareModel: Effect.Effect; readonly transcribe: ( pcmBytes: Uint8Array, @@ -467,6 +469,7 @@ const make = Effect.gen(function* () { size: definition.size, supportsStreaming: definition.supportsStreaming, supportsBufferedStreaming: true, + supportsModelLinking: true, supportsTranslation: definition.supportsTranslation, languages: definition.languages, supportsLanguageDetection: definition.supportsLanguageDetection, @@ -489,8 +492,8 @@ const make = Effect.gen(function* () { const models = yield* Effect.forEach( SPEECH_MODELS, (definition) => - store.isReady(definition).pipe( - Effect.map((ready): EnvironmentSpeechModel => { + Effect.all([store.isReady(definition), store.linkedFrom(definition)]).pipe( + Effect.map(([ready, linkedFrom]): EnvironmentSpeechModel => { const operation = downloading?.modelId === definition.id ? downloading : undefined; return { id: definition.id, @@ -514,6 +517,7 @@ const make = Effect.gen(function* () { ? "installed" : "downloadable", ...(operation ? { downloaded: operation.downloaded } : {}), + ...(ready && linkedFrom ? { linkedFrom } : {}), }; }), ), @@ -951,6 +955,21 @@ const make = Effect.gen(function* () { ), ), ), + linkModels: Effect.fn("SpeechService.linkModels")(function* (source) { + yield* requireSupported; + const settings = yield* readSettings("model linking"); + const linked = yield* exclusive( + "model linking", + store + .link(SPEECH_MODELS, source) + .pipe(Effect.mapError((cause) => speechError("model linking", cause))), + ); + const first = linked[0]; + if (first === undefined) return yield* new SpeechModelNotFoundError({ modelId: source }); + if (!(yield* store.isReady(selectedModel(settings)))) + yield* writeSettings("model linking", { speechModelId: first }); + return yield* modelsEffect; + }), updateAcceleration: Effect.fn("SpeechService.updateAcceleration")((acceleration) => writeSettings("acceleration update", { speechAcceleration: acceleration }).pipe( Effect.andThen(freshStatus("acceleration update")), diff --git a/apps/server/src/speech/http.test.ts b/apps/server/src/speech/http.test.ts index f67129be60423..d86f77fbdbd83 100644 --- a/apps/server/src/speech/http.test.ts +++ b/apps/server/src/speech/http.test.ts @@ -80,6 +80,7 @@ effectIt.live("rejects oversized chunked audio without invoking transcription", prepareModel: Effect.void, models: () => Effect.succeed({ models: [] }), downloadModel: () => Effect.succeed({ models: [] }), + linkModels: () => Effect.succeed({ models: [] }), selectModel: () => Effect.succeed({ models: [] }), cancelDownload: () => Effect.succeed({ models: [] }), updateAcceleration: () => Effect.succeed({ supported: false as const, reason: "test" }), @@ -164,7 +165,31 @@ effectIt.effect( HttpApiBuilder.layer(HttpApi.make("environment").add(EnvironmentVoiceHttpApi)).pipe( Layer.provide(speechHttpApiLayer), Layer.provide(principal(AuthOrchestrationOperateScope, AuthSettingsWriteScope)), - Layer.provide(Layer.mock(SpeechService.SpeechService)({})), + Layer.provide( + Layer.mock(SpeechService.SpeechService)({ + models: () => + Effect.succeed({ + models: [ + { + id: "fixture/model", + name: "Fixture", + description: "", + size: 1, + languages: ["en"], + accuracy: 0, + speed: 0, + recommended: false, + supportsStreaming: false, + supportsLanguageDetection: false, + supportsTranslation: false, + active: true, + state: "installed", + linkedFrom: "/home/someone/models/fixture.gguf", + }, + ], + }), + }), + ), Layer.provide(cloud), Layer.provide(dependencies), Layer.provide(HttpServer.layerServices), @@ -228,6 +253,22 @@ effectIt.effect( 422, ); expect(transcribe).toHaveBeenLastCalledWith("openrouter", "acme/transcribe-1"); + // Naming a path on the environment needs filesystem access, not only settings access. + const link = yield* Effect.promise(() => + handler( + new Request("http://localhost/api/voice/models/link", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: "/models" }), + }), + context, + ), + ); + expect(link.status).toBe(403); + const listed = yield* Effect.promise(() => + handler(new Request("http://localhost/api/voice/models"), context), + ); + expect(yield* Effect.promise(() => listed.text())).not.toContain("/home/someone"); }).pipe(Effect.scoped), ); @@ -268,6 +309,7 @@ effectIt.effect.each([ prepareModel: Effect.void, models: () => Effect.succeed({ models: [] }), downloadModel: () => Effect.fail(error), + linkModels: () => Effect.fail(error), selectModel: () => Effect.fail(error), cancelDownload: () => Effect.fail(error), updateAcceleration: () => Effect.fail(error), diff --git a/apps/server/src/speech/http.ts b/apps/server/src/speech/http.ts index eb4a7a01b88fb..bf91b4c1ef3a6 100644 --- a/apps/server/src/speech/http.ts +++ b/apps/server/src/speech/http.ts @@ -1,9 +1,12 @@ import { decodeSpeechPcmRequest } from "@t3tools/shared/speech"; import { + AuthFilesystemReadScope, AuthOrchestrationOperateScope, AuthSettingsWriteScope, type AuthEnvironmentScope, + EnvironmentAuthenticatedPrincipal, EnvironmentHttpApi, + type EnvironmentSpeechModels, EnvironmentVoiceBodyLimit, SPEECH_MAX_OPTIONS_BYTES, } from "@t3tools/contracts"; @@ -53,6 +56,14 @@ const mapSpeechError = (effect: Effect.Effect model) }; +}); + export const speechHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "voice", @@ -133,35 +144,54 @@ export const speechHttpApiLayer = HttpApiBuilder.group( "models", Effect.fn("environment.voice.models")(function* (args) { yield* guard(args.endpoint.name); - return yield* speech.models(args.query.benchmarks === "1").pipe(mapSpeechError); + return yield* speech + .models(args.query.benchmarks === "1") + .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths)); }), ) .handle( "downloadModel", Effect.fn("environment.voice.downloadModel")(function* (args) { yield* guard(args.endpoint.name, AuthSettingsWriteScope); - return yield* speech.downloadModel(args.payload.modelId).pipe(mapSpeechError); + return yield* speech + .downloadModel(args.payload.modelId) + .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths)); }), ) .handle( "selectModel", Effect.fn("environment.voice.selectModel")(function* (args) { yield* guard(args.endpoint.name, AuthSettingsWriteScope); - return yield* speech.selectModel(args.payload.modelId).pipe(mapSpeechError); + return yield* speech + .selectModel(args.payload.modelId) + .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths)); }), ) .handle( "cancelModelDownload", Effect.fn("environment.voice.cancelModelDownload")(function* (args) { yield* guard(args.endpoint.name, AuthSettingsWriteScope); - return yield* speech.cancelDownload(args.payload.modelId).pipe(mapSpeechError); + return yield* speech + .cancelDownload(args.payload.modelId) + .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths)); }), ) .handle( "removeModel", Effect.fn("environment.voice.removeModel")(function* (args) { yield* guard(args.endpoint.name, AuthSettingsWriteScope); - return yield* speech.removeModel(args.payload.modelId).pipe(mapSpeechError); + return yield* speech + .removeModel(args.payload.modelId) + .pipe(mapSpeechError, Effect.flatMap(withoutHostPaths)); + }), + ) + .handle( + "linkModels", + Effect.fn("environment.voice.linkModels")(function* (args) { + yield* guard(args.endpoint.name, AuthSettingsWriteScope); + // The request names a path on this machine and the result reveals what is there. + yield* requireEnvironmentScope(AuthFilesystemReadScope); + return yield* speech.linkModels(args.payload.path).pipe(mapSpeechError); }), ) .handle( diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 40fd6e7eed9c2..86c6cb9a8cfef 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, + ProjectId, ProviderDriverKind, type ModelSelection, type ServerProvider, @@ -542,6 +543,7 @@ const PREFLIGHT_PRIMARY_STATES = [ "pending", "exhausted", "not installed", + "not installed, fallback disabled for project", "signed out", "signed out, fallback failed", ] as const; @@ -550,6 +552,7 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima Effect.gen(function* () { const primary = ProviderInstanceId.make("codex"); const fallback = ProviderInstanceId.make("claude_work"); + const projectId = ProjectId.make("project-a"); const selection = { instanceId: primary, model: "chat-model", @@ -573,7 +576,7 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima ...(primaryState === "pending" && instanceId === primary ? { installed: false, status: "warning", auth: { status: "unknown" } } : {}), - ...(primaryState === "not installed" && instanceId === primary + ...(primaryState.startsWith("not installed") && instanceId === primary ? { installed: false, status: "error", auth: { status: "unknown" } } : {}), ...(primaryState.startsWith("signed out") && instanceId === primary @@ -625,6 +628,13 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima providerInstances: { [fallback]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, }, + ...(primaryState === "not installed, fallback disabled for project" + ? { + projectSettingsOverrides: { + [projectId]: { providerInstances: { [fallback]: { enabled: false } } }, + }, + } + : {}), }), Layer.mock(TextGenerators.TextGenerators)({ runner: () => Effect.undefined }), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ @@ -639,11 +649,13 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima message: "hello", linkedContext: "", modelSelection: selection, + projectId, }), ).toEqual({ title: "Generated title" }); expect(calls).toEqual([ primaryState === "ready" || primaryState === "pending" || + primaryState === "not installed, fallback disabled for project" || primaryState === "signed out, fallback failed" ? selection : { diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index fcdddd2052d8e..612d473071bcf 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,7 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import { isProviderAvailable, isProviderTextGenerationCapable, @@ -81,6 +81,7 @@ export const make = Effect.gen(function* () { const generate = Effect.fn("TextGeneration.generate")(function* ( operation: TextGenerationOp, selection: ModelSelection, + projectId: ProjectId | null | undefined, invoke: ( service: ProviderTextGeneration, modelSelection: ModelSelection, @@ -129,7 +130,7 @@ export const make = Effect.gen(function* () { return ( instanceId !== selection.instanceId && provider?.enabled === true && - resolveProjectProviderInstanceEnabled(settings, null, instanceId) && + resolveProjectProviderInstanceEnabled(settings, projectId ?? null, instanceId) && provider.installed && provider.auth.status !== "unauthenticated" && provider.status !== "error" && @@ -155,23 +156,35 @@ export const make = Effect.gen(function* () { }); return TextGeneration.of({ generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) { - return generate("generateCommitMessage", input.modelSelection, (service, modelSelection) => - service.generateCommitMessage({ ...input, modelSelection }), + return generate( + "generateCommitMessage", + input.modelSelection, + input.projectId, + (service, modelSelection) => service.generateCommitMessage({ ...input, modelSelection }), ); }), generatePrContent: Effect.fn("TextGeneration.generatePrContent")(function (input) { - return generate("generatePrContent", input.modelSelection, (service, modelSelection) => - service.generatePrContent({ ...input, modelSelection }), + return generate( + "generatePrContent", + input.modelSelection, + input.projectId, + (service, modelSelection) => service.generatePrContent({ ...input, modelSelection }), ); }), generateBranchName: Effect.fn("TextGeneration.generateBranchName")(function (input) { - return generate("generateBranchName", input.modelSelection, (service, modelSelection) => - service.generateBranchName({ ...input, modelSelection }), + return generate( + "generateBranchName", + input.modelSelection, + input.projectId, + (service, modelSelection) => service.generateBranchName({ ...input, modelSelection }), ); }), generateReviewGuide: Effect.fn("TextGeneration.generateReviewGuide")(function (input) { - return generate("generateReviewGuide", input.modelSelection, (service, modelSelection) => - service.generateReviewGuide({ ...input, modelSelection }), + return generate( + "generateReviewGuide", + input.modelSelection, + input.projectId, + (service, modelSelection) => service.generateReviewGuide({ ...input, modelSelection }), ); }), generateTranscriptionPostProcessing: Effect.fn( @@ -180,6 +193,7 @@ export const make = Effect.gen(function* () { return generate( "generateTranscriptionPostProcessing", input.modelSelection, + null, (service, modelSelection) => service.generateTranscriptionPostProcessing({ ...input, modelSelection }), ); @@ -188,6 +202,7 @@ export const make = Effect.gen(function* () { return generate( "generateThreadTitle", input.modelSelection, + input.projectId, (textGeneration, modelSelection) => Effect.gen(function* () { const linkedContext = diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index b9f357522bb7b..431af7b5971ff 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -1,4 +1,5 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -48,12 +49,15 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); function fixture( options: { - accounts?: Array< - (typeof accounts)[number] & { - disabled?: boolean; - id_token: { chatgpt_account_id: string; plan_type?: string; chatgpt_plan_type?: string }; - } - >; + accounts?: Array<{ + id: string; + auth_index: string; + provider: string; + email?: string; + disabled?: boolean; + project_id?: string; + id_token?: { chatgpt_account_id: string; plan_type?: string; chatgpt_plan_type?: string }; + }>; upstream?: (request: RequestBody) => { status: number; body: unknown }; cooldownStatus?: number; } = {}, @@ -433,6 +437,188 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + const antigravitySummary = { + buckets: [ + { + bucketId: "gemini-weekly", + window: "weekly", + remainingFraction: 0.75, + resetTime: "2099-01-01T00:00:00Z", + }, + ], + }; + + it.effect("reads an xAI account as Grok with Grok CLI headers", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ id: "xai.json", auth_index: "x", provider: "xai", email: "x@example.com" }], + upstream: () => ({ + status: 200, + body: { + config: { + creditUsagePercent: 40, + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end: "2099-01-01T00:00:00Z" }, + }, + }, + }), + }); + const [account] = yield* (yield* test.api).readAccounts(config); + expect(account).toMatchObject({ driver: "grok", email: "x@example.com" }); + expect(account?.usageLimits.windows).toMatchObject([ + { id: "subscription", kind: "weekly", usedPercent: 40 }, + ]); + const call = test.requests.find((request) => request.body?.url)?.body; + expect(call?.method).toBe("GET"); + expect(call?.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits"); + expect(call?.header).toMatchObject({ + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-version": expect.any(String), + "User-Agent": expect.stringMatching(/^grok-cli\//), + }); + }), + ); + + it.effect("reads an Antigravity summary and keys the account by its Google ID", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [ + { + id: "antigravity-g@example.com.json", + auth_index: "g", + provider: "antigravity", + email: "g@example.com", + project_id: "project-7", + }, + ], + upstream: (request) => ({ + status: 200, + body: request.url?.endsWith("/userinfo") ? { id: "google-42" } : antigravitySummary, + }), + }); + const [account] = yield* (yield* test.api).readAccounts(config); + expect(account?.driver).toBe("antigravity"); + expect(account?.email).toBeUndefined(); + expect(account?.usageLimits.credentialFingerprint).toBe( + // sha256("google-account\0google-42"), as a local Antigravity probe reports it. + "136d4952771fd67a3600053f37492cbd05828712e0b1c89ebab03d4c22ab6554", + ); + expect(account?.usageLimits.windows).toMatchObject([ + { id: "gemini-weekly", kind: "weekly", usedPercent: 25 }, + ]); + const summary = test.requests.find((request) => request.body?.method === "POST")?.body; + expect(summary?.url).toBe( + "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", + ); + expect(summary?.data).toBe(encodeJson({ project: "project-7" })); + expect(summary).toMatchObject({ + auth_index: "g", + method: "POST", + header: { + Authorization: "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": + "antigravity/hub/2.24.0 (aidev_client; os_type=linux; arch=x64; cl=963137146)", + }, + }); + const identityCalls = test.requests.filter((request) => + request.body?.url?.endsWith("/userinfo"), + ); + expect(identityCalls).toHaveLength(1); + expect(identityCalls[0]?.body).toMatchObject({ + method: "GET", + url: "https://www.googleapis.com/oauth2/v2/userinfo", + header: { Authorization: "Bearer $TOKEN$" }, + }); + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Architecture, "x64"), + ), + ); + + it.effect("keeps Antigravity limits and listing email when its identity read fails", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [ + { + id: "antigravity.json", + auth_index: "g", + provider: "antigravity", + email: "g@example.com", + project_id: "project-7", + }, + ], + upstream: (request) => + request.url?.endsWith("/userinfo") + ? { status: 403, body: { error: "denied" } } + : { + status: 200, + body: antigravitySummary, + }, + }); + const result = yield* (yield* test.api).readAccounts(config); + expect(result).toHaveLength(1); + const [account] = result; + expect(account).toMatchObject({ driver: "antigravity", email: "g@example.com" }); + expect(account?.usageLimits.credentialFingerprint).toBeUndefined(); + expect(account?.usageLimits.unavailable).toBeUndefined(); + expect(account?.usageLimits.windows).toMatchObject([ + { id: "gemini-weekly", kind: "weekly", usedPercent: 25 }, + ]); + }), + ); + + it.effect.each([ + { name: "a refused xAI call", provider: "xai", status: 403, body: { error: "denied" } }, + { name: "a malformed xAI answer", provider: "xai", status: 200, body: { config: "x" } }, + { name: "a refused Antigravity call", provider: "antigravity", status: 401, body: {} }, + { name: "a malformed Antigravity answer", provider: "antigravity", status: 200, body: [] }, + { name: "an empty Antigravity summary", provider: "antigravity", status: 200, body: {} }, + ])("reports $name as unreadable", ({ provider, status, body }) => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ id: `${provider}.json`, auth_index: "a", provider, project_id: "project-7" }], + upstream: () => ({ status, body }), + }); + const [account] = yield* (yield* test.api).readAccounts(config); + expect(account?.usageLimits.unavailable?.reason).toBe("probeFailed"); + }), + ); + + it.effect.each([undefined, "", " "])( + "rejects a missing or blank Antigravity project (%s) before upstream calls", + (project_id) => + Effect.gen(function* () { + const test = fixture({ + accounts: [ + { + id: "antigravity.json", + auth_index: "a", + provider: "antigravity", + ...(project_id === undefined ? {} : { project_id }), + }, + ], + }); + const [account] = yield* (yield* test.api).readAccounts(config); + expect(account?.usageLimits.unavailable?.reason).toBe("probeFailed"); + expect(test.requests.map((request) => request.path)).toEqual(["/v0/management/auth-files"]); + }), + ); + + it.effect("skips accounts of providers the hub table does not know", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [ + { id: "kiro.json", auth_index: "k", provider: "kiro", email: "k@example.com" }, + { ...accounts[0]!, provider: "claude" }, + ], + upstream: () => ({ status: 200, body: { five_hour: { utilization: 1, resets_at: null } } }), + }); + const result = yield* (yield* test.api).readAccounts(config); + expect(result.map((account) => account.driver)).toEqual(["claudeAgent"]); + expect(test.requests.map((request) => request.body?.auth_index)).not.toContain("k"); + }), + ); + it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () => Effect.gen(function* () { const test = fixture(); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 87ec1e05b8f77..030983f50e33d 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -5,18 +5,33 @@ import { type UsageLimitSourceAccount, type UsageLimitSourceConfig, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { + GROK_USAGE_URL, + GrokUsageResponse, + grokUsageResponseToLimits, +} from "@t3tools/provider-grok/server"; import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; -import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +import { + antigravityAccountFingerprint, + antigravitySummaryWindows, + antigravityUserAgent, +} from "../provider/antigravityUsageLimits.ts"; +import { + makeUnavailableUsageLimits, + makeUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; /** A hub with its management key already read from wherever the setting keeps it. */ export interface CliproxyHub { @@ -30,6 +45,7 @@ const AuthFile = Schema.Struct({ provider: Schema.String, email: Schema.optional(Schema.String), disabled: Schema.optional(Schema.Boolean), + project_id: Schema.optional(Schema.String), id_token: Schema.optional( Schema.Struct({ chatgpt_account_id: Schema.optional(Schema.String), @@ -106,6 +122,8 @@ const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse); const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList)); const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeUsage)); const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage)); +const decodeGrokUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(GrokUsageResponse)); +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const isUsageLimitSourceError = Schema.is(UsageLimitSourceError); // Extra CAs can fix untrusted chains; expiry/hostname failures report codes only. @@ -174,9 +192,216 @@ const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(fun return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; }); +type AuthFile = typeof AuthFile.Type; +/** Sends one upstream request with the account's token, as GET or as a JSON POST of `data`. */ +type HubCall = ( + url: string, + data?: unknown, +) => Effect.Effect; +type HubReading = Omit; +interface HubProvider { + readonly driver: ProviderDriverKind; + readonly header: ( + account: AuthFile, + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, + ) => Record; + readonly read: (input: { + readonly account: AuthFile; + readonly call: HubCall; + readonly checkedAt: string; + }) => Effect.Effect; +} + +const listedIdentity = (account: AuthFile) => ({ + ...(account.email ? { email: account.email } : {}), + ...(account.id_token?.chatgpt_account_id?.trim() + ? { workspaceId: account.id_token.chatgpt_account_id.trim() } + : {}), +}); + +const credits = Effect.fn("CliproxyApi.credits")(function* (call: HubCall) { + const response = yield* decodeCreditList(yield* call(CREDIT_URL)); + const now = DateTime.toEpochMillis(yield* DateTime.now); + return response.credits + .filter( + (credit) => + credit.reset_type === "codex_rate_limits" && + credit.status === "available" && + Date.parse(credit.expires_at) > now, + ) + .toSorted((a, b) => Date.parse(a.expires_at) - Date.parse(b.expires_at)); +}); + +const CODEX_HUB: HubProvider = { + driver: ProviderDriverKind.make("codex"), + header: (account) => ({ + Authorization: "Bearer $TOKEN$", + "Content-Type": "application/json", + "OpenAI-Beta": "codex-1", + Originator: "Codex Desktop", + ...(account.id_token?.chatgpt_account_id + ? { "Chatgpt-Account-Id": account.id_token.chatgpt_account_id } + : {}), + }), + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + const usage = yield* decodeCodexUsage(yield* call(`${CODEX_BASE}/usage`)); + const toWindow = (window: typeof CodexWindow.Type | null | undefined) => + window + ? { + usedPercent: window.used_percent, + resetsAt: window.reset_at ?? null, + ...(window.limit_window_seconds === undefined + ? {} + : { windowDurationMins: window.limit_window_seconds / 60 }), + } + : null; + const available = yield* credits(call).pipe(Effect.orElseSucceed(() => undefined)); + const next = available?.[0]; + const planType = + usage.plan_type ?? account.id_token?.plan_type ?? account.id_token?.chatgpt_plan_type; + return { + ...listedIdentity(account), + plan: codexPlanLabel(planType), + usageLimits: { + ...codexRateLimitsToLimits({ + checkedAt, + snapshot: { + planType: planType ?? null, + primary: toWindow(usage.rate_limit?.primary_window), + secondary: toWindow(usage.rate_limit?.secondary_window), + }, + }), + ...(available + ? { + resetCredits: { + availableCount: available.length, + ...(next + ? { + nextCreditId: next.id, + nextExpiresAt: DateTime.formatIso(DateTime.makeUnsafe(next.expires_at)), + } + : {}), + }, + } + : {}), + }, + }; + }), +}; + +const GROK_CLI_VERSION = "1.0.77"; +const ANTIGRAVITY_SUMMARY_URL = + "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; + +// Keyed by the auth-files `provider`; accounts of any other provider are skipped. +const HUB_PROVIDERS = new Map([ + ["codex", CODEX_HUB], + [ + "claude", + { + driver: ProviderDriverKind.make("claudeAgent"), + header: () => ({ + Authorization: "Bearer $TOKEN$", + "anthropic-beta": "oauth-2025-04-20", + // The OAuth usage endpoint can return 429 without a Claude Code user agent. + "User-Agent": "claude-code/2.1.273", + }), + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + const usage = yield* decodeClaudeUsage( + yield* call("https://api.anthropic.com/api/oauth/usage"), + ); + const model_scoped = (usage.limits ?? []).flatMap((limit) => + limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" + ? [ + { + display_name: limit.scope.model.display_name, + utilization: limit.percent, + resets_at: limit.resets_at ?? null, + }, + ] + : [], + ); + const rateLimits = { + five_hour: usage.five_hour ?? null, + seven_day: usage.seven_day ?? null, + model_scoped, + spend: usage.spend, + extra_usage: usage.extra_usage ?? null, + }; + return { + ...listedIdentity(account), + plan: "Claude Subscription", + usageLimits: claudeUsageResponseToLimits({ + checkedAt, + response: { rate_limits_available: true, rate_limits: rateLimits }, + }).limits, + }; + }), + }, + ], + [ + "xai", + { + driver: ProviderDriverKind.make("grok"), + header: () => ({ + Authorization: "Bearer $TOKEN$", + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-version": GROK_CLI_VERSION, + "User-Agent": `grok-cli/${GROK_CLI_VERSION}`, + }), + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + const usage = yield* decodeGrokUsage(yield* call(GROK_USAGE_URL)); + return { + ...listedIdentity(account), + usageLimits: grokUsageResponseToLimits(usage, checkedAt), + }; + }), + }, + ], + [ + "antigravity", + { + driver: ProviderDriverKind.make("antigravity"), + header: (_account, platform, architecture) => ({ + Authorization: "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(platform, architecture), + }), + read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { + const project = account.project_id?.trim(); + if (!project) { + return yield* new UsageLimitSourceError({ detail: "No Antigravity project reported." }); + } + const requestedAt = DateTime.toEpochMillis(yield* DateTime.now); + const summary = yield* call(ANTIGRAVITY_SUMMARY_URL, { project }); + const windows = yield* antigravitySummaryWindows(yield* decodeJson(summary), requestedAt); + if (windows.length === 0) { + return yield* new UsageLimitSourceError({ detail: "No Antigravity quota reported." }); + } + // Local Antigravity reports no e-mail, so the Google account fingerprint is what pools the two. + const fingerprint = yield* call("https://www.googleapis.com/oauth2/v2/userinfo").pipe( + Effect.flatMap(decodeJson), + Effect.flatMap(antigravityAccountFingerprint), + Effect.option, + ); + const usageLimits = makeUsageLimits({ checkedAt, windows }); + return Option.match(fingerprint, { + onNone: () => ({ ...listedIdentity(account), usageLimits }), + onSome: (credentialFingerprint) => ({ + usageLimits: { ...usageLimits, credentialFingerprint }, + }), + }); + }), + }, + ], +]); + export const makeCliproxyApi = Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const digest = yield* Digest.Digest; + const platform = yield* HostProcess.Platform; + const architecture = yield* HostProcess.Architecture; const management = Effect.fn("CliproxyApi.management")(function* ( hub: CliproxyHub, @@ -215,27 +440,11 @@ export const makeCliproxyApi = Effect.gen(function* () { const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( hub: CliproxyHub, - account: typeof AuthFile.Type, + account: AuthFile, + header: Record, url: string, data?: unknown, ) { - const header = - account.provider === "codex" - ? { - Authorization: "Bearer $TOKEN$", - "Content-Type": "application/json", - "OpenAI-Beta": "codex-1", - Originator: "Codex Desktop", - ...(account.id_token?.chatgpt_account_id - ? { "Chatgpt-Account-Id": account.id_token.chatgpt_account_id } - : {}), - } - : { - Authorization: "Bearer $TOKEN$", - "anthropic-beta": "oauth-2025-04-20", - // The OAuth usage endpoint can return 429 without a Claude Code user agent. - "User-Agent": "claude-code/2.1.273", - }; const raw = yield* management(hub, "api-call", { auth_index: account.auth_index, method: data === undefined ? "GET" : "POST", @@ -252,117 +461,18 @@ export const makeCliproxyApi = Effect.gen(function* () { return response.body; }); - const credits = Effect.fn("CliproxyApi.credits")(function* ( - hub: CliproxyHub, - account: typeof AuthFile.Type, - ) { - const body = yield* apiCall(hub, account, CREDIT_URL); - const response = yield* decodeCreditList(body); - const now = DateTime.toEpochMillis(yield* DateTime.now); - return response.credits - .filter( - (credit) => - credit.reset_type === "codex_rate_limits" && - credit.status === "available" && - Date.parse(credit.expires_at) > now, - ) - .toSorted((a, b) => Date.parse(a.expires_at) - Date.parse(b.expires_at)); - }); - const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( hub: CliproxyHub, - account: typeof AuthFile.Type, + account: AuthFile, + provider: HubProvider, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const base = { - id: account.id, - driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), - ...(account.email ? { email: account.email } : {}), - ...(account.id_token?.chatgpt_account_id?.trim() - ? { workspaceId: account.id_token.chatgpt_account_id.trim() } - : {}), - }; - const read = Effect.gen(function* () { - if (account.provider === "claude") { - const body = yield* apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage"); - const usage = yield* decodeClaudeUsage(body); - const model_scoped = (usage.limits ?? []).flatMap((limit) => - limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" - ? [ - { - display_name: limit.scope.model.display_name, - utilization: limit.percent, - resets_at: limit.resets_at ?? null, - }, - ] - : [], - ); - const rateLimits = { - five_hour: usage.five_hour ?? null, - seven_day: usage.seven_day ?? null, - model_scoped, - spend: usage.spend, - extra_usage: usage.extra_usage ?? null, - }; - return { - ...base, - plan: "Claude Subscription", - usageLimits: claudeUsageResponseToLimits({ - checkedAt, - response: { - rate_limits_available: true, - rate_limits: rateLimits, - }, - }).limits, - }; - } - const body = yield* apiCall(hub, account, `${CODEX_BASE}/usage`); - const usage = yield* decodeCodexUsage(body); - const toWindow = (window: typeof CodexWindow.Type | null | undefined) => - window - ? { - usedPercent: window.used_percent, - resetsAt: window.reset_at ?? null, - ...(window.limit_window_seconds === undefined - ? {} - : { windowDurationMins: window.limit_window_seconds / 60 }), - } - : null; - const available = yield* credits(hub, account).pipe(Effect.orElseSucceed(() => undefined)); - const next = available?.[0]; - const planType = - usage.plan_type ?? account.id_token?.plan_type ?? account.id_token?.chatgpt_plan_type; - return { - ...base, - plan: codexPlanLabel(planType), - usageLimits: { - ...codexRateLimitsToLimits({ - checkedAt, - snapshot: { - planType: planType ?? null, - primary: toWindow(usage.rate_limit?.primary_window), - secondary: toWindow(usage.rate_limit?.secondary_window), - }, - }), - ...(available - ? { - resetCredits: { - availableCount: available.length, - ...(next - ? { - nextCreditId: next.id, - nextExpiresAt: DateTime.formatIso(DateTime.makeUnsafe(next.expires_at)), - } - : {}), - }, - } - : {}), - }, - }; - }); - return yield* read.pipe( + const call: HubCall = (url, data) => + apiCall(hub, account, provider.header(account, platform, architecture), url, data); + const reading = yield* provider.read({ account, call, checkedAt }).pipe( + Effect.provideService(Digest.Digest, digest), Effect.orElseSucceed(() => ({ - ...base, + ...listedIdentity(account), usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed", @@ -370,6 +480,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }), })), ); + return { id: account.id, driver: provider.driver, ...reading }; }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( @@ -383,11 +494,11 @@ export const makeCliproxyApi = Effect.gen(function* () { ), ); return yield* Effect.forEach( - accounts.filter( - (account) => - !account.disabled && (account.provider === "codex" || account.provider === "claude"), - ), - (account) => readAccount(hub, account), + accounts.flatMap((account) => { + const provider = HUB_PROVIDERS.get(account.provider); + return provider && !account.disabled ? [{ account, provider }] : []; + }), + ({ account, provider }) => readAccount(hub, account, provider), { concurrency: 4 }, ); }); @@ -404,13 +515,19 @@ export const makeCliproxyApi = Effect.gen(function* () { detail: "The Codex hub account is missing or disabled.", }); } - const body = yield* apiCall(hub, account, `${CREDIT_URL}/consume`, { - redeem_request_id: yield* creditRedeemRequestId( - account.id_token?.chatgpt_account_id ?? account.id, - creditId, - ).pipe(Effect.provideService(Digest.Digest, digest)), - credit_id: creditId, - }); + const body = yield* apiCall( + hub, + account, + CODEX_HUB.header(account, platform, architecture), + `${CREDIT_URL}/consume`, + { + redeem_request_id: yield* creditRedeemRequestId( + account.id_token?.chatgpt_account_id ?? account.id, + creditId, + ).pipe(Effect.provideService(Digest.Digest, digest)), + credit_id: creditId, + }, + ); const response = yield* decodeConsumeResponse(body); const outcome = ( { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8a8c4e1be719c..9721864fcdfd5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2649,7 +2649,7 @@ export const layerWsRpc = ( detail: "This provider does not bank reset credits.", }); } - const outcome = yield* instance.consumeResetCredit().pipe( + const outcome = yield* instance.consumeResetCredit(input.creditId).pipe( Effect.mapError( (error) => new ProviderSetupError({ diff --git a/apps/web/package.json b/apps/web/package.json index e1dc0fbf90c1e..6cb69713aec27 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -111,7 +111,7 @@ "compression": "^1.8.2", "micromark-util-types": "catalog:", "oxc-transform-react": "~0.152.0", - "playwright": "1.60.0", + "playwright": "1.64.1", "tailwindcss": "^4.0.0", "vite": "catalog:", "vite-plus": "catalog:" diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 76b77f15ea674..b03612507f917 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -9,6 +9,7 @@ import "../index.css"; import { filesystemEnvironment } from "../state/filesystem"; import { useRightPanelStore } from "../rightPanelStore"; +import { remarkEnableOrderedListNumbers } from "@t3tools/shared/markdownPipeline"; import { setMarkdownTaskChecked } from "@t3tools/shared/markdownTaskList"; import { HostImageUrlContext } from "./chat/hostImageUrl"; @@ -162,6 +163,38 @@ function buttonsLabelled(container: HTMLElement, label: string) { ); } +const SOURCE_NUMBER_PLUGINS = [remarkEnableOrderedListNumbers]; + +it("keeps decimal markers in nested user lists while documents keep their counter styles", async () => { + setup(); + const text = "1. Outer\n 1. Nested\n 7. Skipped\n 1. Deeper\n 4. Fourth"; + await using screen = await render( +
+
+

User message

+ +
+
+

Markdown preview

+ +
+
, + ); + await expect.element(screen.getByText("User message", { exact: true })).toBeVisible(); + const [message, preview] = screen.container.querySelectorAll(".chat-markdown"); + expect( + [...message!.querySelectorAll("ol")].map((list) => getComputedStyle(list).listStyleType), + ).toEqual(["decimal", "decimal", "decimal"]); + expect( + [...preview!.querySelectorAll("ol")].map((list) => getComputedStyle(list).listStyleType), + ).toEqual(["decimal", "lower-alpha", "lower-roman"]); +}); + it("shows unavailable audio when a workspace embed has no thread", async () => { setup(); await using screen = await renderText("![recording](clip.wav)"); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e7e55fd078321..0b0116be549cb 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -322,13 +322,18 @@ function CodexArtifactTemplateCard(props: { const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; function orderedListGutterStyle( - itemCount: number, + itemValues: ReadonlyArray, start: unknown, ): { "--list-gutter": string } | undefined { const parsedStart = Number.parseInt(String(start ?? 1), 10); - const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; - const lastNumber = firstNumber + Math.max(itemCount - 1, 0); - const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); + let number = Number.isNaN(parsedStart) ? 1 : parsedStart; + let markerWidth = String(number).length; + for (const value of itemValues) { + const written = Number.parseInt(String(value ?? ""), 10); + if (!Number.isNaN(written)) number = written; + markerWidth = Math.max(markerWidth, String(number).length); + number += 1; + } if (markerWidth <= 1) return undefined; return { "--list-gutter": `${markerWidth + 2}ch` }; } @@ -1017,10 +1022,11 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, ol: function MarkdownOrderedList({ node, start, style, ...props }) { - const itemCount = - node?.children?.filter((child) => child.type === "element" && child.tagName === "li") - .length ?? 0; - const gutterStyle = orderedListGutterStyle(itemCount, start); + const itemValues = + node?.children?.flatMap((child) => + child.type === "element" && child.tagName === "li" ? [child.properties.value] : [], + ) ?? []; + const gutterStyle = orderedListGutterStyle(itemValues, start); return (
    ); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a73322fc65114..a6dd4fe1d40c7 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2650,6 +2650,7 @@ function OpenCommandPaletteDialog(props: { scrollCommandPaletteRowIntoView( resultListRef.current, resultRows.rowIndexByItemIndex[0] ?? 0, + true, ); return; } @@ -2921,7 +2922,11 @@ function OpenCommandPaletteDialog(props: { setHighlightedItemValue(typeof value === "string" ? value : null); const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index]; if (eventDetails.reason === "keyboard" && rowIndex !== undefined) { - scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex); + scrollCommandPaletteRowIntoView( + resultListRef.current, + rowIndex, + eventDetails.index === 0, + ); } }} onValueChange={(nextQuery, { event }) => handleQueryChange(nextQuery, event.target)} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index f337f555382b5..864c190b89c54 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -78,7 +78,15 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { ); } -export function scrollCommandPaletteRowIntoView(list: LegendListRef | null, rowIndex: number) { +export function scrollCommandPaletteRowIntoView( + list: LegendListRef | null, + rowIndex: number, + isFirstItem: boolean, +) { + if (isFirstItem) { + void list?.scrollToOffset({ offset: 0, animated: false }); + return; + } void list?.scrollIndexIntoView({ index: rowIndex, animated: false, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index a1a69a7968ee9..8da54f5f72141 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -31,9 +31,11 @@ const promptBefore = `See ${serializeAssistantCitation(citation)} `; function Composer({ question, editorRef, + send = "clears", }: { question: boolean; editorRef: React.RefObject; + send?: "clears" | "blocked" | "refusedLater"; }) { const [prompt, setPrompt] = useState(promptBefore); const [answer, setAnswer] = useState(""); @@ -61,6 +63,11 @@ function Composer({ onCitationDraftSave={(replacedValue, savedValue) => { if (replacedValue === prompt) setPrompt(savedValue); }} + onCitationSubmitAndSend={() => { + if (send === "blocked") return false; + if (send === "clears") setPrompt(""); + return true; + }} onPaste={() => {}} /> @@ -97,3 +104,58 @@ it("keeps an unsaved citation comment when a question takes the composer", async ); await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument(); }); + +it("clears the sent prompt when an empty citation comment sends it", async () => { + const editorRef = createRef(); + await using screen = await render(); + + const sourceAnchor = findAssistantCitationSourceAnchor(document, citation); + if (!sourceAnchor) throw new Error("Citation source did not resolve"); + await act(async () => + editorRef.current?.requestCitationComment({ + previousValue: "See ", + value: promptBefore, + citationStart: "See ".length, + sourceAnchor, + insertedSpaces: { before: false, after: true }, + }), + ); + const comment = page.getByLabelText("Comment on selected text"); + await expect.element(comment).toHaveFocus(); + await userEvent.keyboard("{Control>}{Enter}{/Control}"); + + await expect.element(comment).not.toBeInTheDocument(); + await expect.element(page.getByTestId("composer-editor")).toHaveFocus(); + await expect.element(screen.getByRole("status")).toHaveAttribute("data-prompt", ""); +}); + +it.each(["blocked", "refusedLater"] as const)( + "keeps the caret after the citation when save-and-send is %s", + async (send) => { + const editorRef = createRef(); + await using screen = await render( + , + ); + + const sourceAnchor = findAssistantCitationSourceAnchor(document, citation); + if (!sourceAnchor) throw new Error("Citation source did not resolve"); + await act(async () => + editorRef.current?.requestCitationComment({ + previousValue: "See ", + value: promptBefore, + citationStart: "See ".length, + sourceAnchor, + insertedSpaces: { before: false, after: true }, + }), + ); + const comment = page.getByLabelText("Comment on selected text"); + await expect.element(comment).toHaveFocus(); + await userEvent.keyboard("{Control>}{Enter}{/Control}"); + await expect.element(comment).not.toBeInTheDocument(); + await userEvent.keyboard("x"); + + await expect + .element(screen.getByRole("status")) + .toHaveAttribute("data-prompt", `See ${serializeAssistantCitation(citation)}x `); + }, +); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index bb8f5a5a2211a..1986cc888efba 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -21,6 +21,7 @@ import { Plugin, PluginKey, TextSelection, + type Transaction, } from "@tiptap/pm/state"; import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; @@ -186,7 +187,7 @@ export interface ComposerPromptEditorProps { onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; - onCitationSubmitAndSend?: () => void; + onCitationSubmitAndSend?: () => boolean; /** Saves unsaved citation text into replacedValue when a controlled rewrite replaces the document. */ onCitationDraftSave?: (replacedValue: string, savedValue: string) => void; onPaste: React.ClipboardEventHandler; @@ -213,12 +214,12 @@ const ComposerCitationCommentContext = createContext<{ openComment: OpenCitationComment | null; onOpenChange: (citeKey: string, open: boolean) => void; onDraftChange: (citeKey: string, comment: string) => void; - onSubmitAndSend: () => void; + onSubmitAndSend: () => boolean; }>({ openComment: null, onOpenChange: () => {}, onDraftChange: () => {}, - onSubmitAndSend: () => {}, + onSubmitAndSend: () => false, }); const RichComposerSkillsContext = createContext>([]); @@ -477,13 +478,23 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { editor.chain().focus().deleteRange({ from, to }).run(); }; + const sentRef = useRef(false); const onRestoreFocus = () => { + const sent = sentRef.current; + sentRef.current = false; if (!editor.isEditable) return; const pos = nodePos(); - if (pos === null) return; - const current = editor.state.doc.nodeAt(pos); - if (!current || current.type.name !== "composer-citation") return; - editor.commands.focus(pos + current.nodeSize); + const current = pos === null ? null : editor.state.doc.nodeAt(pos); + if (pos === null || current?.type.name !== "composer-citation") { + if (sent) editor.view.focus(); + return; + } + // The send's controlled rewrite may still be pending; emitting this caret move would restore the old prompt. + editor + .chain() + .setMeta(SILENT_SELECTION_META, sent) + .focus(pos + current.nodeSize) + .run(); }; return ( @@ -525,7 +536,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { onDraftChange: (comment) => commentContext.onDraftChange(citeKey, comment), onSaveAndSend: (comment) => { if (!onSaveComment(comment)) return false; - commentContext.onSubmitAndSend(); + sentRef.current = commentContext.onSubmitAndSend(); return true; }, onRestoreFocus, @@ -729,6 +740,8 @@ function chipSelectionDecorations({ doc, selection }: EditorState): DecorationSe type TiptapEditor = NonNullable>; +const SILENT_SELECTION_META = "composerSilentSelection"; + function replaceSkillChips( editor: TiptapEditor, replacements: ReturnType, @@ -995,10 +1008,10 @@ function ComposerPromptEditorTiptapInner( onDraftChange: (nodeKey: string, comment: string) => { citationDraftRef.current = { key: nodeKey, comment }; }, - onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), + onSubmitAndSend: onCitationSubmitAndSend ?? (() => false), }; - const handleEditorChange = (updated: TiptapEditor) => { + const handleEditorChange = (updated: TiptapEditor, transaction: Transaction) => { const map = serializeEditorDoc(updated.state.doc); const contextIds = literalText ? [] : [...map.contextIds]; const { from, to } = updated.state.selection; @@ -1025,7 +1038,7 @@ function ComposerPromptEditorTiptapInner( previousSnapshot.contextIds.length === contextIds.length && previousSnapshot.contextIds.every((id, index) => id === contextIds[index]) ); - if (isApplyingControlledUpdateRef.current) return; + if (isApplyingControlledUpdateRef.current || transaction.getMeta(SILENT_SELECTION_META)) return; if (!snapshotChanged) { if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { readCallbacks().onVisibleSelectionChange?.(); @@ -1459,10 +1472,10 @@ function ComposerPromptEditorTiptapInner( return; } } - handleEditorChange(updated); + handleEditorChange(updated, transaction); }, - onSelectionUpdate: ({ editor: updated }) => { - handleEditorChange(updated); + onSelectionUpdate: ({ editor: updated, transaction }) => { + handleEditorChange(updated, transaction); }, }, [], diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f722fd1eb6e1f..cba7c434b1bab 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2096,7 +2096,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (voiceInputActive) { event?.preventDefault(); submitAfterDictation(() => submitComposerRef.current(dispatchMode, submissionIntent)); - return; + return false; } if ( noProviderAvailable || @@ -2104,7 +2104,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ) { event?.preventDefault(); - return; + return false; } // A send while a pasted image is still compressing would strand that image: // the turn snapshot wouldn't include it, and it would surface in the *next* @@ -2116,7 +2116,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) title: "Still compressing a pasted image.", description: "Send again once its thumbnail appears.", }); - return; + return false; } if (pendingDraftWork.has(attachmentTargetKey)) { event?.preventDefault(); @@ -2125,7 +2125,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) title: "Still bringing attached context into this message.", description: "Send again once its chip resolves.", }); - return; + return false; } const submission = submitComposerDraft({ prompt: promptRef.current, @@ -2148,11 +2148,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, }); setComposerSubmissionError(submission.validationMessage); - if (!submission.didDispatch) return; + if (!submission.didDispatch) return false; onTypingGuardSend(); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } + return true; }; const submitComposerStable = useStableCallback(submitComposer); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed submission handler to the asynchronous dictation completion @@ -2201,7 +2202,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const handleSubmitMessageStable = useStableCallback(handleSubmitMessage); const submitCitationAndSend = () => { - submitComposer( + return submitComposer( undefined, resolveComposerDispatchMode({ running: phase === "running", diff --git a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx index e3560312925e5..ca98b4d2f6c79 100644 --- a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx @@ -6,7 +6,7 @@ import type { SnapShotSource, } from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { CircleAlertIcon, PaperclipIcon, PlayIcon, XIcon } from "lucide-react"; +import { CircleAlertIcon, ImageIcon, PaperclipIcon, PlayIcon, XIcon } from "lucide-react"; import { type ComponentProps, useLayoutEffect, useRef } from "react"; import { @@ -16,7 +16,8 @@ import { composerFileNeedsReattach, } from "../../composerDraftStore"; import { retryAttachmentUpload, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue"; -import { formatAttachmentUploadProgress } from "../../lib/attachmentUploadState"; +import type { AttachmentUploadState } from "../../lib/attachmentUploadState"; +import { useAttachmentUploadProgress } from "../../hooks/useAttachmentUploadProgress"; import { isHeicImageFile } from "../../lib/imageCompression"; import { type PendingSnapShotAnimation, @@ -47,6 +48,30 @@ import { } from "./SnapShotAttachmentDetails"; import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; +function ComposerAttachmentUploadProgress({ + id, + upload, + fallback, +}: { + id: string; + upload: AttachmentUploadState | undefined; + fallback?: string; +}) { + return useAttachmentUploadProgress(id, upload) ?? fallback ?? null; +} + +function ComposerImageFallback() { + return ( + + + ); +} + function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = (video: HTMLVideoElement | null) => { if (!video) return; @@ -214,21 +239,17 @@ export function ComposerAttachmentMediaRow({ name={ready.name} mimeType={ready.mimeType} src={ready.previewUrl} - alt={image.name} + alt="" className="h-full w-full object-cover" /> ) : ( - - {image.name} - + ) } /> ) : ( -
    - {image.name} -
    + )} {source?.kind === "snap-shot" ? ( )} - {(ready ? upload?.status === "uploading" : supportsAttachmentUploads) && ( + {(!ready || upload?.status === "uploading") && ( - {formatAttachmentUploadProgress( - upload?.status === "uploading" ? upload.progress : 0, + {upload?.status === "uploading" ? ( + + ) : ( + "Preparing" )} )} @@ -359,7 +382,7 @@ export function ComposerAttachmentMediaRow({ {upload?.status === "uploading" && ( - {formatAttachmentUploadProgress(upload.progress)} + )} {upload?.status === "failed" && ( @@ -453,13 +476,21 @@ export function ComposerAttachmentFileList({ {file.name} - {needsReattach - ? canReattachFile - ? "Attach again" - : "Remove to send" - : upload?.status === "uploading" - ? formatAttachmentUploadProgress(upload.progress) - : formatAttachmentSize(file.sizeBytes)} + {needsReattach ? ( + canReattachFile ? ( + "Attach again" + ) : ( + "Remove to send" + ) + ) : upload?.status === "uploading" ? ( + + ) : ( + formatAttachmentSize(file.sizeBytes) + )} {!needsReattach && upload?.status === "failed" ? ( diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.browser.test.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.browser.test.tsx new file mode 100644 index 0000000000000..9994d1552027a --- /dev/null +++ b/apps/web/src/components/chat/ComposerImageThumbnail.browser.test.tsx @@ -0,0 +1,69 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { createComposerImageThumbnail } from "../../lib/imageCompression"; +import { ComposerImageThumbnail } from "./ComposerImageThumbnail"; + +it("keeps a native preview available after thumbnail generation has failed", async () => { + const canvas = document.createElement("canvas"); + canvas.width = 2; + canvas.height = 2; + const blob = await new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!))); + const file = new File([blob], "screenshot.png", { type: "image/png" }); + using _bitmap = vi + .spyOn(window, "createImageBitmap") + .mockRejectedValue(new Error("Thumbnail decoder unavailable")); + expect(await createComposerImageThumbnail(file)).toBeNull(); + + const container = document.createElement("div"); + const holdLoad = (event: Event) => event.stopPropagation(); + container.addEventListener("load", holdLoad, true); + using complete = vi.spyOn(HTMLImageElement.prototype, "complete", "get").mockReturnValue(false); + await using screen = await render( + Preview unavailable} + />, + { container }, + ); + using revoke = vi.spyOn(URL, "revokeObjectURL"); + await expect.element(screen.getByText("Preview unavailable")).toBeVisible(); + const loadingImage = screen.container.querySelector("img")!; + await loadingImage.decode(); + complete.mockRestore(); + container.removeEventListener("load", holdLoad, true); + await act(() => loadingImage.dispatchEvent(new Event("load"))); + const image = screen.getByRole("img", { name: file.name }); + await expect.element(image).toBeVisible(); + await expect.element(screen.getByText("Preview unavailable")).not.toBeInTheDocument(); + const src = image.element().getAttribute("src"); + expect(src).toMatch(/^blob:/); + await screen.unmount(); + expect(revoke).toHaveBeenCalledWith(src); +}); + +it("shows the fallback while HEIC preparation is pending", async () => { + let finishReading!: (bytes: ArrayBuffer) => void; + const metadata = new Promise((resolve) => { + finishReading = resolve; + }); + using _read = vi.spyOn(Blob.prototype, "arrayBuffer").mockReturnValue(metadata); + const file = new File(["pending"], "photo.heic", { type: "image/heic" }); + + await using screen = await render( + } + />, + ); + try { + expect(screen.container.querySelector('[aria-label="Preparing image"]')).not.toBeNull(); + } finally { + finishReading(new ArrayBuffer(0)); + await createComposerImageThumbnail(file); + } +}); diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.tsx index 68158c7151b2f..b6fc4d2b9a482 100644 --- a/apps/web/src/components/chat/ComposerImageThumbnail.tsx +++ b/apps/web/src/components/chat/ComposerImageThumbnail.tsx @@ -33,11 +33,7 @@ export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ let disposed = false; let localUrl: string | undefined; const cached = readComposerImageThumbnail(file); - if ( - file.size <= MAX_COMPRESSIBLE_SOURCE_BYTES && - !isHeicImageFile(file) && - cached === undefined - ) { + if (file.size <= MAX_COMPRESSIBLE_SOURCE_BYTES && !isHeicImageFile(file) && !cached) { localUrl = URL.createObjectURL(file); // oxlint-disable-next-line react/set-state-in-effect -- the blob URL is created and revoked by this effect setPreview({ file, src: localUrl }); @@ -66,17 +62,20 @@ export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ }, [file]); return src && failedSrc !== src ? ( - { - if (image?.complete && image.naturalWidth > 0) setLoadedSrc(src); - }} - src={src} - alt={alt} - onLoad={() => setLoadedSrc(src)} - onError={() => setFailedSrc(src)} - className={cn(className, loadedSrc !== src && "invisible")} - /> - ) : src === undefined ? null : ( + + {loadedSrc !== src && fallback} + { + if (image?.complete && image.naturalWidth > 0) setLoadedSrc(src); + }} + src={src} + alt={alt} + onLoad={() => setLoadedSrc(src)} + onError={() => setFailedSrc(src)} + className={cn(className, loadedSrc !== src && "invisible absolute inset-0")} + /> + + ) : ( fallback ); }); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index a19e7f205cef9..c25eacd506715 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -205,7 +205,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( isCollapsed ? "Show the question and its options" : "Hide the question and its options" } data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"} - className="px-4 pt-1.5" + className="px-4 py-1.5" > {/* Rows span the header row's width so the radios align with the question text and the shortcut column sits under the dismiss button. */}
    diff --git a/apps/web/src/components/chat/LinkRequestCard.tsx b/apps/web/src/components/chat/LinkRequestCard.tsx index a9bdd0fb1114a..f4a7fbfb7c133 100644 --- a/apps/web/src/components/chat/LinkRequestCard.tsx +++ b/apps/web/src/components/chat/LinkRequestCard.tsx @@ -1,6 +1,7 @@ import { pipe } from "effect/Function"; import { useAtomValue } from "@effect/atom-react"; import { + ANOTHER_ADDRESS, type LinkableMachine, linkRequestAnswerInput, linkRequestDefaultAccess, @@ -8,6 +9,7 @@ import { linkRequestFailureMessage, linkRequestInitialMachine, type LinkRequestItem, + linkRequestTarget, linkRequestTargetName, rankMachinesByHint, } from "@t3tools/client-runtime/link-request"; @@ -33,7 +35,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { accessConfig } from "../auth/ConnectAgentSurface"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { LinkAccessPicker } from "../settings/LinkAccessPicker"; -import { ANOTHER_ADDRESS, LinkMachineBadge } from "../settings/LinkMachineSelect"; +import { LinkAddressWarning, LinkMachineBadge } from "../settings/LinkMachineSelect"; import { Button } from "../ui/button"; import { Dialog } from "../ui/dialog"; import { Input } from "../ui/input"; @@ -122,9 +124,6 @@ function PendingLinkRequest(props: { : `Link ${target} to this environment`}

    {item.reason.trim() ?

    {item.reason}

    : null} - {item.label !== undefined && item.url !== undefined ? ( -

    {item.url}

    - ) : null}
    {error !== null ? (

    @@ -161,41 +160,6 @@ function PendingLinkRequest(props: { ); } -/** - * What the dialog links: a machine from the client's catalog, or an address. - * `urls` is empty when the client knows no direct address for the machine, - * so the user types one. - */ -type DialogTarget = - | { readonly kind: "none" } - | { - readonly kind: "machine"; - readonly environmentId: EnvironmentId; - readonly label: string; - readonly urls: ReadonlyArray; - readonly linked: boolean; - } - | { readonly kind: "address" }; - -function resolveTarget( - item: LinkRequestItem, - machines: ReadonlyArray, - choice: string | null, -): DialogTarget { - if (item.environmentId === undefined && item.url !== undefined) return { kind: "address" }; - const chosen = item.environmentId ?? choice; - if (chosen === null) return { kind: "none" }; - if (chosen === ANOTHER_ADDRESS) return { kind: "address" }; - const machine = machines.find((candidate) => candidate.environmentId === chosen); - return { - kind: "machine", - environmentId: machine?.environmentId ?? (chosen as EnvironmentId), - label: machine?.label ?? linkRequestTargetName(item), - urls: [...new Set([...(machine?.urls ?? []), ...(item.url === undefined ? [] : [item.url])])], - linked: machine?.linked ?? false, - }; -} - /** The dialog's steps: where to link, then what agents may do there. */ type LinkStep = "machine" | "access"; @@ -229,19 +193,24 @@ function LinkRequestDialog(props: { reportDefect: false, }); const selected = choice ?? linkRequestInitialMachine(item, machines); - const target = resolveTarget(item, machines, selected); + const target = linkRequestTarget(item, machines, selected); const targetName = target.kind === "machine" ? target.label : linkRequestTargetName(item); - // This client can mint a code there when it is connected to that - // environment with a session that may create pairing links. const targetId = target.kind === "machine" ? target.environmentId : null; const connected = usePreparedConnection(targetId); const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope); - const canMint = - targetId !== null && Option.isSome(connected) && mayMint && !mintFailed.has(targetId); const needsAddress = target.kind === "address" || (target.kind === "machine" && target.urls.length === 0); + // This client mints a code there when it is connected with a session that may + // create pairing links, and only for its own addresses: anything can answer + // as that machine at one typed in, so that one gets a code the user fetched. + const canMint = + targetId !== null && + !needsAddress && + Option.isSome(connected) && + mayMint && + !mintFailed.has(targetId); // An address the agent gave is already filled in; only one the user must type is asked for. - const asksAddress = needsAddress && !(item.url !== undefined && item.environmentId === undefined); + const asksAddress = needsAddress && item.url === undefined; const useExisting = target.kind === "machine" && target.linked; // A named machine with an address it answers at has nothing to choose before access. const steps: ReadonlyArray = @@ -287,10 +256,7 @@ function LinkRequestDialog(props: { } return linkRequestAnswerInput(item, { type: "link", - target: - target.kind === "address" - ? { url: address } - : { ...target, urls: needsAddress ? [address.trim()] : target.urls }, + target: target.kind === "machine" && target.urls.length > 0 ? target : { url: address }, access, pairingCode: code, }); @@ -393,11 +359,12 @@ function LinkRequestDialog(props: { ) : ( <> - {item.url !== undefined && !pickMachine ? ( + {item.url !== undefined && target.kind === "machine" ? (

    {item.url}

    ) : null} {useExisting ? null : ( <> + {needsAddress ? : null} {canMint ? null : (
    {toolbar} @@ -1503,7 +1575,7 @@ function PullRequestCodeTab({ -
    +
    {[...orphanFiles].map(([path, threads]) => (
    @@ -1534,6 +1606,10 @@ function PullRequestCodeTab({
    { const composedPath = event.nativeEvent.composedPath?.() ?? []; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 9d0f6193f7e3f..81dea810bb0d0 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,9 @@ +import { + parsePullRequestAnchor, + pullRequestLinkCommit, + resolvePullRequestReviewAnchor, + type PullRequestAnchor, +} from "@t3tools/shared/pullRequestAnchor"; import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links"; import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useAtomValue } from "@effect/atom-react"; @@ -126,6 +132,8 @@ import { isStackedPullRequestBase, pullRequestActionNeedsHostRefresh, pullRequestCheckoutCommand, + pullRequestTimelineEventMatches, + buildPullRequestTimeline, pullRequestFindingKey, pullRequestHandoffLabels, PULL_REQUEST_MERGE_METHOD_LABELS, @@ -255,6 +263,22 @@ function pullRequestStackReference( }; } +function linkedTab(anchor: PullRequestAnchor | null): PullRequestDetailTab | null { + switch (anchor?.kind) { + case "code": + case "diff": + return "code"; + case "commits": + case "timeline": + return "timeline"; + case "checks": + case "summary": + return "summary"; + default: + return null; + } +} + export function PullRequestDetailPanel({ environmentId, shortcutsEnabled, @@ -271,6 +295,8 @@ export function PullRequestDetailPanel({ onBack, onSelectPullRequest, onGuideShown, + revealUrl, + onRevealSettled, initialTab = "summary", onTabChange, }: { @@ -297,6 +323,10 @@ export function PullRequestDetailPanel({ composerDraftTarget?: ScopedThreadRef | DraftId; onBack?: (() => void) | undefined; onGuideShown?: (() => void) | undefined; + /** A clicked link into this pull request, waiting to be shown. */ + revealUrl?: string | undefined; + /** Reports the link it was given shown or missing, so a newer link stays pending. */ + onRevealSettled?: ((revealUrl: string) => void) | undefined; initialTab?: PullRequestDetailTab; onTabChange?: ((tab: PullRequestDetailTab) => void) | undefined; }) { @@ -355,7 +385,10 @@ export function PullRequestDetailPanel({ coreDetail, detail, } = usePullRequestDetail(environmentId, reference, matchingListEntry); - const [selectedTab, setTab] = useState(initialTab); + const revealAnchor = parsePullRequestAnchor(revealUrl); + const [selectedTab, setTab] = useState( + () => linkedTab(revealAnchor) ?? initialTab, + ); const reviewGuidesEnabled = useEnvironmentSettings( environmentId, (settings) => settings.reviewGuidesEnabled, @@ -379,7 +412,7 @@ export function PullRequestDetailPanel({ const [codeCommitScope, setCodeCommitScope] = useState<{ readonly pullRequestKey: string; readonly oid: string | null; - }>(() => ({ pullRequestKey, oid: null })); + }>(() => ({ pullRequestKey, oid: pullRequestLinkCommit(revealUrl) })); const selectedCodeCommitOid = codeCommitScope.pullRequestKey === pullRequestKey ? codeCommitScope.oid : null; const selectCodeCommit = (oid: string | null) => { @@ -399,6 +432,33 @@ export function PullRequestDetailPanel({ void loadCodeTab(); selectTab("code"); }; + // Cached activity can predate a linked comment, so a miss waits for one host read. `after` is + // when that read's invalidation finished; null while it runs. + const revealHostRead = useRef<{ readonly url: string; readonly after: number | null } | null>( + null, + ); + const settleReveal = (url: string) => { + revealHostRead.current = null; + onRevealSettled?.(url); + }; + const followLink = useEffectEvent(() => { + if (revealUrl === undefined) return; + const next = linkedTab(revealAnchor); + if (next === null) return settleReveal(revealUrl); + if (next === "code") { + selectCodeCommit(pullRequestLinkCommit(revealUrl)); + void loadCodeTab(); + } + selectTab(next); + // These name a tab, not a place in it. + if (revealAnchor?.kind === "code" || revealAnchor?.kind === "commits") settleReveal(revealUrl); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a clicked link lives in the panel store and selects its tab even when the panel is already mounted + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- link navigation selects the PR tab and commit scope + followLink(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- each clicked link is a navigation request + }, [revealUrl]); const tabScopeKey = `${environmentId}:${pullRequestKey}`; const [tabMountState, setTabMountState] = useState(() => ({ key: tabScopeKey, @@ -601,14 +661,72 @@ export function PullRequestDetailPanel({ const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); const [isInvalidating, setIsInvalidating] = useState(false); const refreshing = isInvalidating || detailQuery.isPending; - const refreshFromHost = async () => { + /** Resolves whether the host cache was invalidated before the refetch. */ + const refetchFromHost = async () => { setIsInvalidating(true); // Atom commands settle without throwing, so reset always runs. - await invalidate({ environmentId, input: { reference } }); + const invalidated = await invalidate({ environmentId, input: { reference } }); refreshDetail(); setRefreshToken((token) => token + 1); setIsInvalidating(false); + return invalidated._tag === "Success"; + }; + const refreshFromHost = async () => { + await refetchFromHost(); }; + const timelineAnchor = + revealAnchor?.kind !== "timeline" + ? null + : detail === null + ? revealAnchor.anchor + : resolvePullRequestReviewAnchor( + revealAnchor.anchor, + detail.comments, + detail.reviewThreads, + ); + // Loaded activity without the linked item, keyed by load so a refetch is checked again. + const revealMissedAt = + timelineAnchor !== null && + detail !== null && + !activityQuery.isPending && + !buildPullRequestTimeline(detail).some((event) => + pullRequestTimelineEventMatches(event, timelineAnchor), + ) + ? activityQuery.dataUpdatedAt + : null; + const revealReadFailed = revealMissedAt !== null && activityQuery.error !== null; + const readHostForReveal = useEffectEvent(() => { + if (revealUrl === undefined || revealMissedAt === null) return; + const read = revealHostRead.current; + if (read?.url !== revealUrl) { + const request = { url: revealUrl, after: null }; + revealHostRead.current = request; + void refetchFromHost().then((invalidated) => { + if (revealHostRead.current !== request) return; + // Without an invalidation the refetch could only repeat the cached read. + if (!invalidated) return settleReveal(request.url); + revealHostRead.current = { url: request.url, after: Date.now() }; + }); + return; + } + // A read that finished before the invalidation cannot show the item is missing. `after` is + // stamped once the refetch was requested, so a read in that same millisecond is the new one. + if (read.after !== null && (revealReadFailed || revealMissedAt >= read.after)) { + settleReveal(revealUrl); + } + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a linked comment missing from cached activity asks the host once before the link settles + useEffect(() => { + readHostForReveal(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- each load that misses the linked item is checked against the host read + }, [revealMissedAt, revealReadFailed, revealUrl]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a host read outliving the panel must not settle a later link + useEffect( + () => () => { + revealHostRead.current = null; + }, + [], + ); const refreshFromHostOnRequest = useEffectEvent(() => void refreshFromHost()); const appliedForcedToken = useRef(forcedRefreshToken); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a new forced refresh token invalidates the host cache and refetches @@ -1746,6 +1864,8 @@ export function PullRequestDetailPanel({
    diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index 8e3ee75602342..4d5668937880e 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -1,5 +1,6 @@ import type { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { parsePullRequestAnchor, pullRequestLinkCommit } from "@t3tools/shared/pullRequestAnchor"; import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; import { cloneElement, @@ -18,7 +19,12 @@ import { useEnvironmentQuery } from "~/state/query"; import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { PullRequestActorAvatar, resolvePullRequestState } from "./pullRequestPresentation"; +import { pullRequestLinkSubject, pullRequestReviewOutcome } from "./pullRequestDetail.logic"; +import { + PullRequestActorAvatar, + PullRequestReviewOutcomeBadge, + resolvePullRequestState, +} from "./pullRequestPresentation"; interface PullRequestLinkPreviewTarget { readonly environmentId: EnvironmentId; @@ -57,6 +63,20 @@ export function PullRequestLinkPreview({ }) : null, ); + // A link to a comment or commit shows that, read from the activity the panel shares. + const namesSubject = + parsePullRequestAnchor(originalUrl)?.kind === "timeline" || + pullRequestLinkCommit(originalUrl) !== null; + const activityQuery = useEnvironmentQuery( + open && namesSubject + ? pullRequestEnvironment.activity({ + environmentId: target.environmentId, + input: target.input, + }) + : null, + ); + const subject = + activityQuery.data === null ? null : pullRequestLinkSubject(originalUrl, activityQuery.data); const summary = useSharedPullRequestSummary( open ? target.environmentId : null, open ? target.input : null, @@ -136,10 +156,16 @@ export function PullRequestLinkPreview({ )}
    -

    - {card.title} -

    - {card.author === undefined ? null : ( + {subject?.kind === "comment" ? ( + + ) : subject?.kind === "commit" ? ( + + ) : ( +

    + {card.title} +

    + )} + {card.author === undefined || subject !== null ? null : (
    {authorLabel} @@ -161,3 +187,68 @@ export function PullRequestLinkPreview({ ); } + +function actorLabel(actor: { readonly login: string } | null | undefined) { + return actor?.login ?? "ghost"; +} + +function LinkedComment({ + subject, +}: { + subject: Extract, { kind: "comment" }>; +}) { + const outcome = pullRequestReviewOutcome(subject.reviewState); + return ( +
    +
    + + + {actorLabel(subject.author)} + + {outcome === null ? ( + commented + ) : ( + + )} + · + + + +
    + {subject.excerpt === null ? null : ( +

    + {subject.excerpt} +

    + )} +
    + ); +} + +function LinkedCommit({ + commit, +}: { + commit: Extract, { kind: "commit" }>["commit"]; +}) { + const author = commit.authors?.[0]; + return ( +
    +

    + {commit.messageHeadline} +

    +
    + {commit.oid.slice(0, 7)} + {author === undefined ? null : ( + <> + · + + {actorLabel(author)} + + )} + · + + + +
    +
    + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index d2ae440fde9e8..23808ed83140e 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -229,7 +229,7 @@ export function ReviewThreadCard({ return (
    event.stopPropagation()} > @@ -250,7 +250,7 @@ export function ReviewThreadCard({ {thread.isOutdated ? outdated : null} {onFix ? (
    - +
    {children}
    @@ -422,6 +435,8 @@ const CommentFilterSchema = Schema.Struct({ bots: Schema.Boolean, resolved: Sche const DEFAULT_COMMENT_FILTER: typeof CommentFilterSchema.Type = { bots: false, resolved: false }; export function PullRequestSummaryTab({ + revealUrl, + onRevealSettled, scrollerRef, environmentId, threadRef, @@ -438,6 +453,9 @@ export function PullRequestSummaryTab({ onRefresh, onRefreshChecks = onRefresh, }: { + /** A clicked link to scroll to, until onRevealSettled reports it shown. */ + revealUrl?: string | undefined; + onRevealSettled?: ((revealUrl: string) => void) | undefined; scrollerRef: (node: HTMLDivElement | null) => void; environmentId: EnvironmentId; threadRef: ScopedThreadRef | null; @@ -562,6 +580,60 @@ export function PullRequestSummaryTab({ const [commentSaving, setCommentSaving] = useState(false); const editingCommentId = commentScope?.pullRequest === detail.url ? commentScope.commentId : null; + const scrollRef = useRef(null); + const location = parsePullRequestAnchor(revealUrl); + const [checksReveals, setChecksReveals] = useState(0); + const [descriptionReveals, setDescriptionReveals] = useState(0); + const [seenRevealUrl, setSeenRevealUrl] = useState(undefined); + if (revealUrl !== seenRevealUrl) { + setSeenRevealUrl(revealUrl); + if (location?.kind === "checks") setChecksReveals((count) => count + 1); + if (location?.kind === "summary" && location.anchor !== "top") { + setDescriptionReveals((count) => count + 1); + } + } + const stopHolding = useRef<(() => void) | null>(null); + const revealSummary = useEffectEvent(() => { + const root = scrollRef.current; + if (revealUrl === undefined || !root) return; + let target: HTMLElement | null | undefined; + if (location?.kind === "checks") { + target = root.querySelector( + '[data-pull-request-summary-section][aria-label="Checks"]', + ); + } else if (location?.kind === "summary") { + if (location.anchor === "top") { + stopHolding.current?.(); + root.scrollTop = 0; + return onRevealSettled?.(revealUrl); + } + target = /^issue-\d+$/.test(location.anchor) + ? root.querySelector("[data-pr-description]") + : [...root.querySelectorAll("[id]")].find( + (node) => node.id === location.anchor || node.id === `user-content-${location.anchor}`, + ); + } + // A heading may still be rendering; the link stays pending until it appears. + if (!target) return; + stopHolding.current?.(); + const heading = target.hasAttribute("data-pull-request-summary-section") + ? null + : target.closest("[data-pull-request-summary-section]")?.firstElementChild; + stopHolding.current = holdLinkedNodeInView( + target, + root, + () => (heading?.getBoundingClientRect().height ?? 0) + (heading ? 8 : 0), + ); + onRevealSettled?.(revealUrl); + }); + const editingDescription = bodyScope?.url === detail.url; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls to a linked section, description or heading once it renders + useEffect(() => { + revealSummary(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- retries once the description markdown renders, including after its editor closes + }, [revealUrl, description, checksReveals, descriptionReveals, editingDescription]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the hold observes DOM outside React and must end with the tab + useEffect(() => () => stopHolding.current?.(), []); // Checkbox ticks and the editor share one draft, so each starts from the other's latest body. const saveDescription = async (body: string) => { const key = detail.url; @@ -733,7 +805,14 @@ export function PullRequestSummaryTab({ }; return ( -
    +
    { + scrollRef.current = node; + scrollerRef(node); + }} + className="h-full overflow-y-auto" + data-pull-request-summary-scroll + >
    } label="Reviewers"> @@ -838,9 +917,14 @@ export function PullRequestSummaryTab({
    -
    +
    - {bodyScope?.url === detail.url ? ( + {editingDescription ? ( setBodyScope(null)} /> ) : ( -
    +
    0 ? description : "_No description provided._"} @@ -875,7 +959,12 @@ export function PullRequestSummaryTab({
    -
    +
    {checksStale ? (
    Check details are out of date. diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.browser.test.tsx index 4e8f1e4319c5c..847cbb60aa650 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.browser.test.tsx @@ -4,7 +4,7 @@ import { SourceControlProviderKind, type PullRequestDetailView, } from "@t3tools/contracts"; -import { expect, it } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { render } from "../../../test/render"; import "../../index.css"; @@ -87,3 +87,90 @@ it("mounts only the timeline rows near the viewport of a huge pull request", asy expect(mounted).toBeGreaterThan(0); expect(mounted).toBeLessThan(COMMITS / 10); }); + +it.each(["review", "issue-comment", "resolved-reply"] as const)( + "reveals a linked %s below the virtual viewport", + async (kind) => { + const url = `${detail.url}#${kind === "review" ? "pullrequestreview-123" : kind === "issue-comment" ? "issuecomment-123" : "discussion_r123"}`; + const linkedDetail: PullRequestDetailView = { + ...detail, + comments: + kind === "resolved-reply" + ? [] + : [ + { + id: "linked", + kind, + author: detail.author, + body: "Linked reply", + createdAt: "2026-10-01T00:00:00Z", + url, + path: null, + reviewState: kind === "review" ? "CHANGES_REQUESTED" : null, + }, + ], + reviewThreads: + kind !== "resolved-reply" + ? [] + : [ + { + id: "thread", + path: "src/test.ts", + line: 5, + side: "right", + isResolved: true, + isOutdated: false, + comments: [ + { + id: "root", + author: detail.author, + body: "Root comment", + createdAt: "2026-10-01T00:00:00Z", + url: `${detail.url}#discussion_r122`, + }, + { + id: "reply", + author: detail.author, + body: "Linked reply", + createdAt: "2026-10-01T00:01:00Z", + url, + }, + ], + }, + ], + }; + const settled = vi.fn(); + await using screen = await render( +
    + {}} + detail={linkedDetail} + environmentId={EnvironmentId.make("environment")} + reference={detail} + order="oldest" + onOpenCommit={() => {}} + onRefresh={() => {}} + revealUrl={url} + onRevealSettled={settled} + /> +
    , + ); + await expect.element(screen.getByText("Linked reply", { exact: true })).toBeVisible(); + await vi.waitFor(() => expect(settled).toHaveBeenCalledWith(url)); + await screen.rerender( +
    + {}} + detail={linkedDetail} + environmentId={EnvironmentId.make("environment")} + reference={detail} + order="oldest" + onOpenCommit={() => {}} + onRefresh={() => {}} + onRevealSettled={settled} + /> +
    , + ); + await expect.element(screen.getByText("Linked reply", { exact: true })).toBeVisible(); + }, +); diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 36dca98e23179..df0e9c260d88b 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -1,3 +1,9 @@ +import { + parsePullRequestAnchor, + pullRequestAnchorMatchesUrl, + resolvePullRequestReviewAnchor, +} from "@t3tools/shared/pullRequestAnchor"; +import { holdLinkedNodeInView } from "./holdLinkedNodeInView"; import { capitalizeTerm, sourceControlClients, @@ -20,7 +26,7 @@ import { MessageSquareIcon, MessageSquarePlusIcon, } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -35,6 +41,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { buildPullRequestTimeline, groupPullRequestTimelineConversations, + pullRequestTimelineEventMatches, type PullRequestTimelineRow, isPullRequestVerdictStale, newestPullRequestCommitAt, @@ -66,6 +73,7 @@ import { import { LegendList, type LegendListRef } from "@legendapp/list/react"; interface ReactionSurface { + revealAnchor?: string | null; readonly canReact: boolean; /** Absent where there is no composer beside the panel to add a remark to. */ readonly onAddToChat?: ((comment: PullRequestComment) => void) | undefined; @@ -259,7 +267,16 @@ function ConversationCard({ }; return ( -
    +
    @@ -339,19 +356,25 @@ function uniqueConversationActors(events: ReadonlyArray; editable: ReadonlyMap; cwd: string; onOpen: (url: string) => void; reactions: ReactionSurface; }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState( + () => + revealAnchor != null && + events.some((event) => pullRequestAnchorMatchesUrl(revealAnchor, event.url)), + ); const actors = uniqueConversationActors(events); const first = events[0]; if (first === undefined) return null; @@ -413,6 +436,7 @@ function ConversationGroup({ } function ReviewThreadEvent({ + revealAnchor, event, thread, editable, @@ -420,6 +444,7 @@ function ReviewThreadEvent({ onOpen, reactions, }: { + revealAnchor?: string | null; event: PullRequestTimelineEvent; thread: PullRequestTimelineThread; editable: ReadonlyMap; @@ -428,7 +453,14 @@ function ReviewThreadEvent({ reactions: ReactionSurface; }) { const dismissed = pullRequestReviewOutcome(event.reviewState) === "dismissed"; - const [expanded, setExpanded] = useState(!thread.isResolved && !dismissed); + const [expanded, setExpanded] = useState( + () => + (revealAnchor != null && + [event, ...thread.replies].some((item) => + pullRequestAnchorMatchesUrl(revealAnchor, item.url), + )) || + (!thread.isResolved && !dismissed), + ); const replyCount = thread.replies.length + thread.moreReplies; return ( @@ -675,6 +707,8 @@ function ReviewVerdictEvent({ } export function PullRequestTimelineTab({ + revealUrl, + onRevealSettled, scrollerRef, detail, environmentId, @@ -685,6 +719,9 @@ export function PullRequestTimelineTab({ onAddToChat, onRefresh, }: { + /** A clicked link to scroll to and mark, until onRevealSettled reports it shown. */ + revealUrl?: string | undefined; + onRevealSettled?: ((revealUrl: string) => void) | undefined; scrollerRef: (node: HTMLElement | null) => void; detail: PullRequestDetailView; environmentId: EnvironmentId; @@ -696,9 +733,29 @@ export function PullRequestTimelineTab({ onRefresh: () => void; }) { const events = buildPullRequestTimeline(detail); + const location = parsePullRequestAnchor(revealUrl); + const revealAnchor = location?.kind === "timeline" ? location.anchor : null; + // The linked item stays marked and open after it is shown, until another link replaces it. + const [linked, setLinked] = useState<{ readonly anchor: string; readonly count: number } | null>( + () => (revealAnchor === null ? null : { anchor: revealAnchor, count: 1 }), + ); + const [seenRevealUrl, setSeenRevealUrl] = useState(revealUrl); + if (revealUrl !== seenRevealUrl) { + setSeenRevealUrl(revealUrl); + // Each click remounts the linked row, so it opens again even after the user closed it. + if (revealAnchor !== null) setLinked({ anchor: revealAnchor, count: (linked?.count ?? 0) + 1 }); + } + const anchor = linked + ? resolvePullRequestReviewAnchor(linked.anchor, detail.comments, detail.reviewThreads) + : null; + const listRef = useRef(null); + const matches = (event: PullRequestTimelineEvent) => + anchor !== null && pullRequestTimelineEventMatches(event, anchor); + const term = sourceControlClients.get(detail.provider).changeRequest.singular; const newestCommitAt = newestPullRequestCommitAt(detail.commits); const reactions: ReactionSurface = { + revealAnchor: anchor, canReact: detail.capabilities.reactions === true, onAddToChat: onAddToChat && ((comment) => onAddToChat({ kind: "comment", comment })), environmentId, @@ -716,23 +773,48 @@ export function PullRequestTimelineTab({ ); const orderedEvents = order === "newest" ? events : events.toReversed(); const rows = groupPullRequestTimelineConversations(orderedEvents); + const targetIndex = rows.findIndex((row) => + row.kind === "comments" ? row.events.some(matches) : matches(row.event), + ); + const stopHolding = useRef<(() => void) | null>(null); + const reveal = useEffectEvent(() => { + const list = listRef.current; + const url = revealUrl; + if (url === undefined || revealAnchor === null || targetIndex < 0 || !list) return; + stopHolding.current?.(); + void list.scrollToIndex({ index: targetIndex, viewPosition: 0, animated: false }).then(() => { + const scroller = list.getScrollableNode(); + const node = scroller?.querySelector("[data-pr-link-target=true]"); + if (scroller && node) stopHolding.current = holdLinkedNodeInView(node, scroller, () => 16); + onRevealSettled?.(url); + }); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals a link destination after activity loading and virtual list mounting + useEffect(() => { + reveal(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- runs for each clicked link and again when activity resolves its row + }, [revealUrl, targetIndex]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the hold observes DOM outside React and must end with the tab + useEffect(() => () => stopHolding.current?.(), []); const openOnHost = (url: string) => { void readLocalApi()?.shell.openExternal(url); }; const rowKey = (row: PullRequestTimelineRow) => { - if (row.kind === "comments") return `comments:${row.events[0]?.id ?? "empty"}`; + if (row.kind === "comments") + return `comments:${row.events[0]?.id ?? "empty"}:${row.events.some(matches) ? (linked?.count ?? "") : ""}`; const event = row.event; // A thread's resolution or dismissal remounts it, so its disclosure starts in the new state. return event.thread === undefined - ? event.id - : `thread:${event.id}:${event.thread.isResolved}:${pullRequestReviewOutcome(event.reviewState) === "dismissed"}`; + ? `${event.id}:${matches(event) ? (linked?.count ?? "") : ""}` + : `thread:${event.id}:${matches(event) ? (linked?.count ?? "") : ""}:${event.thread.isResolved}:${pullRequestReviewOutcome(event.reviewState) === "dismissed"}`; }; const renderRow = ({ item: row }: { item: PullRequestTimelineRow }) => { if (row.kind === "comments") { return ( - ref={(list: LegendListRef | null) => scrollerRef(list?.getScrollableNode() ?? null)} + ref={(list: LegendListRef | null) => { + listRef.current = list; + scrollerRef(list?.getScrollableNode() ?? null); + }} data={rows} extraData={{ editable, reactions, newestCommitAt }} keyExtractor={rowKey} @@ -793,7 +879,15 @@ export function PullRequestTimelineTab({ estimatedItemSize={140} className="scrollbar-gutter-both h-full px-4" contentContainerClassName="relative mx-auto w-full max-w-3xl before:absolute before:bottom-10 before:left-[15px] before:top-6 before:w-px before:bg-border/45" - ListHeaderComponent={
    } + ListHeaderComponent={ + anchor !== null && targetIndex < 0 && revealUrl === undefined ? ( +

    + The linked item is not in the loaded activity. +

    + ) : ( +
    + ) + } ListFooterComponent={
    } /> ); diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 2af8383bebcd4..0d494d923e33f 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -1027,10 +1027,13 @@ export function PullRequestsPage() { if (rightPanelState.isOpen) setMaximized((value) => !value); }; const onGuideShown = () => setMaximized(true); + // updateSearch leaves selectedUrl out, so a reload does not reveal the link again. + const consumeLinkedUrl = useEffectEvent(() => updateSearch({})); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the URL-linked pull request in the right panel store useEffect(() => { if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); + if (linkedSelection.revealUrl !== undefined) consumeLinkedUrl(); }, [linkedSelection, pullRequestsSupported, rightPanelRef]); const selected = @@ -1521,6 +1524,13 @@ export function PullRequestsPage() { getShortcutContext={getShortcutContext} shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id} key={renderedPullRequestSurface.id} + revealUrl={renderedPullRequestSurface.revealUrl} + onRevealSettled={(revealUrl) => { + if (rightPanelRef === null) return; + useRightPanelStore + .getState() + .settlePullRequestReveal(rightPanelRef, renderedPullRequestSurface.id, revealUrl); + }} environmentId={panelEnvironmentId} onGuideShown={onGuideShown} onSelectPullRequest={(reference) => { diff --git a/apps/web/src/components/pullRequest/holdLinkedNodeInView.ts b/apps/web/src/components/pullRequest/holdLinkedNodeInView.ts new file mode 100644 index 0000000000000..4261cb2fb0da8 --- /dev/null +++ b/apps/web/src/components/pullRequest/holdLinkedNodeInView.ts @@ -0,0 +1,39 @@ +import { observeResize } from "~/lib/observeResize"; + +const USER_SCROLL_INTENT = ["wheel", "touchstart", "pointerdown", "keydown"] as const; + +/** + * Keeps a linked node `offset()` below the scroller's top while the content around it opens and + * its markdown loads, until the user scrolls, the node leaves, or the returned stop runs. Only the + * scroller moves; scrollIntoView would also scroll the panel's clipped ancestors. + */ +export function holdLinkedNodeInView( + node: HTMLElement, + scroller: HTMLElement, + offset: () => number, +): () => void { + const controller = new AbortController(); + const stop = () => { + unobserve(); + controller.abort(); + }; + const align = () => { + if (!node.isConnected) return stop(); + scroller.scrollTop += + node.getBoundingClientRect().top - scroller.getBoundingClientRect().top - offset(); + }; + const unobserve = observeResize([...scroller.children], align); + for (const type of USER_SCROLL_INTENT) { + scroller.addEventListener(type, stop, { passive: true, signal: controller.signal }); + } + align(); + // Activity can load after the click; by then the user may be typing elsewhere. + const active = document.activeElement; + if ( + !(active instanceof HTMLElement) || + !(active.isContentEditable || active.matches("input, textarea, select")) + ) { + node.focus({ preventScroll: true }); + } + return stop; +} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 5b298d23c429b..6ea7d6a5b0e1a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -50,6 +50,7 @@ import { shouldRefreshPullRequestActivity, resolveBaseFreshness, buildPullRequestTimeline, + pullRequestLinkSubject, writePullRequestDetailSnapshot, readableFailure, } from "./pullRequestDetail.logic"; @@ -313,6 +314,58 @@ describe("review verdicts", () => { }); describe("pull request timeline", () => { + it("names the comment or commit a link points at for its hover card", () => { + const pr = "https://github.com/o/r/pull/1"; + const sha = "a".repeat(40); + const activity = { + comments: [ + { + ...TIMELINE_SOURCE.comments[0]!, + url: `${pr}#issuecomment-5`, + body: "\n> [!NOTE]\n> **Looks** good, see `run()` ![shot](x.png)", + }, + ], + reviewThreads: [ + { + id: "t", + path: "a.ts", + line: 1, + side: "right" as const, + isResolved: false, + isOutdated: false, + comments: [ + { + id: "r1", + author: { login: "reviewer", name: null, avatarUrl: null }, + body: "nit", + createdAt: "2026-07-03T00:00:00Z", + url: `${pr}#discussion_r1`, + reviewUrl: `${pr}#pullrequestreview-9`, + }, + ], + }, + ], + commits: [{ oid: sha, messageHeadline: "fix it", committedDate: "2026-07-02T00:00:00Z" }], + }; + expect(pullRequestLinkSubject(`${pr}#issuecomment-5`, activity)).toMatchObject({ + kind: "comment", + excerpt: "Looks good, see run()", + }); + // A review with no text of its own is shown through the comment it posted. + expect(pullRequestLinkSubject(`${pr}#pullrequestreview-9`, activity)).toMatchObject({ + kind: "comment", + author: { login: "reviewer" }, + excerpt: "nit", + }); + expect(pullRequestLinkSubject(`${pr}/commits/${sha}`, activity)).toMatchObject({ + kind: "commit", + commit: { messageHeadline: "fix it" }, + }); + for (const url of [pr, `${pr}/files`, `${pr}/checks`, `${pr}#issuecomment-6`]) { + expect(pullRequestLinkSubject(url, activity), url).toBeNull(); + } + }); + it("orders host timestamps by instant across offsets and fractional seconds", () => { const events = buildPullRequestTimeline({ ...TIMELINE_SOURCE, diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 537de10a2a8c1..cceadfbee3c60 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1,3 +1,9 @@ +import { + parsePullRequestAnchor, + pullRequestAnchorMatchesUrl, + pullRequestLinkCommit, + resolvePullRequestReviewAnchor, +} from "@t3tools/shared/pullRequestAnchor"; import { stripInlineContextReferences } from "~/lib/composerContextReferences"; import type { Nodes as MdastNodes } from "mdast"; import { fromMarkdown } from "mdast-util-from-markdown"; @@ -17,6 +23,7 @@ import { type PullRequestCheck, type PullRequestChecksState as ContractPullRequestChecksState, type PullRequestComment, + type PullRequestActivity, type PullRequestCommit, type PullRequestContextMetadata, type PullRequestDetailView, @@ -694,6 +701,80 @@ function readableBody(raw: string): string | null { * Merged wins over closed: GitHub sets both timestamps on a merge, and reporting "closed" for a * merged pull request would misstate what happened. */ +export type PullRequestLinkSubject = + | { + readonly kind: "comment"; + readonly author: PullRequestActor | null; + readonly createdAt: string; + readonly reviewState: string | null; + readonly excerpt: string | null; + } + | { readonly kind: "commit"; readonly commit: PullRequestCommit }; + +const LINK_EXCERPT_LENGTH = 280; + +/** The words of a markdown body, without markup, HTML or images, for a short preview. */ +function markdownExcerpt(body: string): string | null { + const words: string[] = []; + const visit = (node: MdastNodes) => { + if (node.type === "text" || node.type === "inlineCode") words.push(node.value); + if (node.type !== "code" && "children" in node) for (const child of node.children) visit(child); + }; + visit(fromMarkdown(body)); + // GitHub's alert marker (`> [!NOTE]`) parses as text but reads as markup. + const text = words + .join(" ") + .replace(/\[!(?:note|tip|important|warning|caution)\]/giu, "") + .replace(/\s+/gu, " ") + .trim(); + if (text === "") return null; + return text.length > LINK_EXCERPT_LENGTH ? `${text.slice(0, LINK_EXCERPT_LENGTH - 1)}…` : text; +} + +/** The comment or commit a pull request link names, for its hover card; null for the PR itself. */ +export function pullRequestLinkSubject( + url: string, + activity: Pick, +): PullRequestLinkSubject | null { + const oid = pullRequestLinkCommit(url)?.toLowerCase(); + if (oid !== undefined) { + const commit = activity.commits.find((candidate) => candidate.oid.toLowerCase() === oid); + return commit === undefined ? null : { kind: "commit", commit }; + } + const location = parsePullRequestAnchor(url); + if (location?.kind !== "timeline") return null; + const anchor = resolvePullRequestReviewAnchor( + location.anchor, + activity.comments, + activity.reviewThreads, + ); + const comment = activity.comments.find((candidate) => + pullRequestAnchorMatchesUrl(anchor, candidate.url), + ); + const threadComment = activity.reviewThreads + .flatMap((thread) => thread.comments) + .find((candidate) => pullRequestAnchorMatchesUrl(anchor, candidate.url)); + const found = comment ?? threadComment; + if (found === undefined) return null; + return { + kind: "comment", + author: found.author, + createdAt: found.createdAt, + reviewState: comment?.reviewState ?? null, + excerpt: markdownExcerpt(found.body), + }; +} + +/** Whether a link anchor names this event or a reply in its review thread. */ +export function pullRequestTimelineEventMatches( + event: PullRequestTimelineEvent, + anchor: string, +): boolean { + return [event, ...(event.thread?.replies ?? [])].some((item) => + pullRequestAnchorMatchesUrl(anchor, item.url), + ); +} + export function buildPullRequestTimeline( detail: Pick< PullRequestDetailView, diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts index fe22fe5af2838..2fad034b2362e 100644 --- a/apps/web/src/components/pullRequest/usePullRequestListView.ts +++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts @@ -40,6 +40,7 @@ import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request- import { pullRequestListEntryId } from "./pullRequestListGroups.logic"; export interface PullRequestsSearch extends PullRequestListPreferences { + readonly selectedUrl?: string; readonly environmentId?: EnvironmentId; readonly projectId?: ProjectId; readonly host?: string; @@ -139,6 +140,16 @@ export function usePullRequestScope(search: PullRequestsSearch) { const assignmentKey = environmentQueries .map(({ environmentId, projectIds }) => `${environmentId}#${projectIds?.join("+") ?? "*"}`) .join("|"); + // The URL is user-controlled and stands in for the PR link until detail loads. + const selectedLink = search.selectedUrl + ? sourceControlClients.parseChangeRequestUrl(search.selectedUrl)?.link + : undefined; + const selectedUrl = + selectedLink !== undefined && + selectedLink.number === search.number && + selectedLink.repository.toLowerCase() === search.repository?.toLowerCase() + ? search.selectedUrl + : undefined; const linkedSelection = search.repository && search.number && selectedProject ? { @@ -146,6 +157,7 @@ export function usePullRequestScope(search: PullRequestsSearch) { repository: search.repository, number: search.number, projectId: selectedProject.id, + ...(selectedUrl ? { url: selectedUrl, revealUrl: selectedUrl } : {}), ...(selectedHost ? { host: selectedHost } : {}), } : null; diff --git a/apps/web/src/components/settings/LinkMachineSelect.tsx b/apps/web/src/components/settings/LinkMachineSelect.tsx index 9977f0bfc699e..4cacafdb8d19f 100644 --- a/apps/web/src/components/settings/LinkMachineSelect.tsx +++ b/apps/web/src/components/settings/LinkMachineSelect.tsx @@ -1,12 +1,31 @@ -import type { LinkableMachine } from "@t3tools/client-runtime/link-request"; +import { + ANOTHER_ADDRESS, + type LinkableMachine, + linkDestination, +} from "@t3tools/client-runtime/link-request"; import { useId } from "react"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Badge } from "../ui/badge"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -/** The machine choice for linking by an address typed in. */ -export const ANOTHER_ADDRESS = "another-address"; +/** + * Shown wherever a link goes to an address this app does not already have for a + * machine, with where that address leads. + */ +export function LinkAddressWarning({ address }: { readonly address: string }) { + return ( + + Check this address + + This app cannot confirm it is one of your machines. The pairing code you paste is sent + there. + {linkDestination(address)} + + + ); +} /** Whether a machine is linked already, or how this app knows it when not connected to it. */ export function LinkMachineBadge({ machine }: { readonly machine: LinkableMachine }) { diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx index 3b19489dd1e97..4cf6997a571c2 100644 --- a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { linkRequestFailureMessage } from "@t3tools/client-runtime/link-request"; +import { ANOTHER_ADDRESS, linkRequestFailureMessage } from "@t3tools/client-runtime/link-request"; import { AuthAccessWriteScope, type AuthMcpClientAccess, @@ -41,7 +41,7 @@ import { Input } from "../ui/input"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; import { LinkAccessPicker } from "./LinkAccessPicker"; -import { ANOTHER_ADDRESS, LinkMachineSelect } from "./LinkMachineSelect"; +import { LinkAddressWarning, LinkMachineSelect } from "./LinkMachineSelect"; import { SettingsRow, SettingsSection } from "./settingsLayout"; import { useNowMinute } from "../../hooks/useNowMinute"; import { searchableSetting } from "./settingsSearch"; @@ -183,14 +183,20 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi const link = useAtomCommand(peerLinkEnvironment.link, { label: "link environment" }); const selected = choice ?? (machines.length === 0 ? ANOTHER_ADDRESS : null); const machine = machines.find((candidate) => candidate.environmentId === selected) ?? null; - // This client can mint a code there when it is connected to that - // environment with a session that may create pairing links. const targetId = machine?.environmentId ?? null; const connected = usePreparedConnection(targetId); const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope); + // A picked machine that left the list has no address here either. + const needsAddress = selected !== null && (machine === null || machine.urls.length === 0); + // This client mints a code there when it is connected with a session that may + // create pairing links, and only for its own addresses: anything can answer + // as that machine at one typed in, so that one gets a code the user fetched. const canMint = - targetId !== null && Option.isSome(connected) && mayMint && !mintFailed.has(targetId); - const needsAddress = selected === ANOTHER_ADDRESS || machine?.urls.length === 0; + targetId !== null && + !needsAddress && + Option.isSome(connected) && + mayMint && + !mintFailed.has(targetId); const ready = selected !== null && (!needsAddress || address.trim().length > 0) && @@ -229,7 +235,8 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi input: { url, ...(alternateUrls.length > 0 ? { alternateUrls } : {}), - ...(machine === null + // A typed address links alone: only this app's own addresses name a machine. + ...(machine === null || needsAddress ? {} : { expectedEnvironmentId: machine.environmentId, expectedLabel: machine.label }), pairingCode: code, @@ -317,6 +324,7 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi ) : null} ) : null} + {needsAddress && address.trim() ? : null} {selected === null || canMint ? null : (

    {model.description}

    + {model.linkedFrom ? ( +

    + Using your file at {model.linkedFrom} +

    + ) : null} - + {model.linkedFrom ? ( + + ) : ( + + )} ) : null}
    @@ -779,6 +795,8 @@ function SpeechModelBrowser(props: { const [search, setSearch] = useState(""); const [sort, setSort] = useState("recommended"); const [downloadedOnly, setDownloadedOnly] = useState(false); + const [existingPath, setExistingPath] = useState(""); + const canReadFiles = useEnvironmentScope(prepared.environmentId, AuthFilesystemReadScope); const [languageSearch, setLanguageSearch] = useState(""); const [selection, setSelection] = useState(null); const [preferred] = useState(preferredSpeechLanguages); @@ -981,6 +999,38 @@ function SpeechModelBrowser(props: { ); })}
    + {status.supportsModelLinking && canReadFiles ? ( +
    { + event.preventDefault(); + void run("Could not use a model from that path", async () => { + await runEnvironmentSpeech( + linkEnvironmentSpeechModels(prepared, existingPath), + ); + setExistingPath(""); + }); + }} + > +
    + setExistingPath(e.target.value)} + /> +
    + +
    + ) : null}
    diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 43ee023af2400..e41aa20998b1c 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -11,12 +11,13 @@ import { import { useAtomValue } from "@effect/atom-react"; import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display"; import { - formatDuration, formatResetsIn, type LimitPace, paceOf, paceRemainingPercent, remainingPercent, + type ResetCreditChoice, + resetCreditChoices, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; import { Fragment, useLayoutEffect, useState } from "react"; @@ -237,22 +238,21 @@ const OUTCOME_TEXT: Record = { alreadyRedeemed: "That credit was already redeemed.", }; -export function useResetCredit( - environmentId: EnvironmentId, - input: ProviderConsumeResetCreditInput, -) { +export function useResetCredit(environmentId: EnvironmentId) { const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); + // Kept after close so the dialog's text survives its exit animation. + const [choice, setChoice] = useState(null); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); const redeem = async () => { setConfirming(false); - if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; + if (!choice || !readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); - const result = await consume({ environmentId, input }); + const result = await consume({ environmentId, input: choice.input }); setBusy(false); if (result._tag === "Success") { setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); @@ -265,7 +265,13 @@ export function useResetCredit( ); }; - return { canManageProviders, confirming, setConfirming, busy, status, redeem }; + const confirm = (next: ResetCreditChoice) => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; + setChoice(next); + setConfirming(true); + }; + + return { canManageProviders, choice, confirming, setConfirming, confirm, busy, status, redeem }; } /** Require confirmation because redemption spends credits. Mount outside popovers, which stack above @@ -273,11 +279,13 @@ export function useResetCredit( export function ResetCreditDialog({ open, onOpenChange, + choice, onConfirm, disabled = false, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; + readonly choice: ResetCreditChoice | null; readonly onConfirm: () => void; readonly disabled?: boolean; }) { @@ -286,10 +294,7 @@ export function ResetCreditDialog({ Use a reset credit? - - This redeems one credit on your account and clears the current rate-limit windows. It - cannot be undone. - + {choice?.confirmDescription} }>Cancel @@ -302,22 +307,6 @@ export function ResetCreditDialog({ ); } -export function resetCreditsSummary( - credits: ServerProviderResetCredits, - now: number, - compact = false, -): string { - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - if (credits.availableCount === 0) return "No reset credits banked"; - if (compact) - return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`; - return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; -} - export function ResetCredits({ environmentId, input, @@ -329,30 +318,31 @@ export function ResetCredits({ readonly credits: ServerProviderResetCredits; readonly now: number; }) { - const { canManageProviders, confirming, setConfirming, busy, status, redeem } = useResetCredit( - environmentId, - input, - ); + const { canManageProviders, choice, confirming, setConfirming, confirm, busy, status, redeem } = + useResetCredit(environmentId); if (credits.availableCount === 0 && status === null) return null; return ( -
    - {resetCreditsSummary(credits, now)} - {credits.availableCount > 0 ? ( - - ) : null} +
    + {resetCreditChoices(credits, input, now).map((choice) => ( +
    + {choice.summary} + {choice.availableCount > 0 ? ( + + ) : null} +
    + ))} {status ? {status} : null} void redeem()} disabled={!canManageProviders} /> diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 00f437246584f..f2d5790ece53b 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -13,6 +13,8 @@ import { type LimitPoolWindow, paceRemainingPercent, remainingPercent, + type ResetCreditChoice, + resetCreditChoices, } from "@t3tools/shared/usageLimits"; import type { Popover as PopoverPrimitive } from "@base-ui/react/popover"; import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display"; @@ -39,14 +41,7 @@ import { Button } from "../ui/button"; import { OpenAI } from "../Icons"; import { Alert, AlertTitle } from "../ui/alert"; import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { - PaceIcon, - ResetCreditDialog, - barColor, - usePaceMark, - resetCreditsSummary, - useResetCredit, -} from "./UsageLimits"; +import { PaceIcon, ResetCreditDialog, barColor, usePaceMark, useResetCredit } from "./UsageLimits"; function accountInitials(email: string): string { const [local = "", domain = ""] = email.split("@"); @@ -146,7 +141,7 @@ function SegmentPopover({ readonly reset: LimitPoolWindow["resets"][number] | undefined; readonly now: number; readonly redeem: ReturnType | null; - readonly onRedeem: () => void; + readonly onRedeem: (choice: ResetCreditChoice) => void; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); const remaining = remainingPercent(window); @@ -156,8 +151,10 @@ function SegmentPopover({ account.environments.length > 0 ? account.environments.map((environment) => environment.label).join(", ") : account.sourceLabel; - const credits = - redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null; + const choices = + redeem && account.redeem && account.limits.resetCredits?.availableCount + ? resetCreditChoices(account.limits.resetCredits, account.redeem.input, now, true) + : []; return (
    @@ -196,20 +193,24 @@ function SegmentPopover({ +{reset.restoresPercent}% of pool ) : null}
    - {credits && redeem ? ( -
    - - {resetCreditsSummary(credits, now, true)} - - + {choices.length > 0 && redeem ? ( +
    + {choices.map((choice) => ( + + {choice.summary} + {choice.availableCount > 0 ? ( + + ) : null} + + ))}
    ) : null}
    @@ -264,9 +265,9 @@ function ActiveSegmentPopover({ { + onRedeem={(choice) => { handle.close(); - payload.redeem?.setConfirming(true); + payload.redeem?.confirm(choice); }} /> @@ -303,13 +304,14 @@ function RedeemableSegment({ }: Omit[0], "redeem"> & { readonly redeemAt: NonNullable; }) { - const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input); + const redeem = useResetCredit(redeemAt.environmentId); return ( <> void redeem.redeem()} disabled={!redeem.canManageProviders} /> diff --git a/apps/web/src/hooks/useAttachmentUploadProgress.browser.test.tsx b/apps/web/src/hooks/useAttachmentUploadProgress.browser.test.tsx new file mode 100644 index 0000000000000..daebd55c0f529 --- /dev/null +++ b/apps/web/src/hooks/useAttachmentUploadProgress.browser.test.tsx @@ -0,0 +1,47 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, fakeTimers, render } from "../../test/render"; +import type { AttachmentUploadState } from "../lib/attachmentUploadState"; +import { useAttachmentUploadProgress } from "./useAttachmentUploadProgress"; + +function Probe({ id = "image", upload }: { id?: string; upload: AttachmentUploadState }) { + const progress = useAttachmentUploadProgress(id, upload); + return {progress ?? "hidden"}; +} + +it("skips fast uploads and delays each attempt without delaying later progress or completion", async () => { + const environmentId = EnvironmentId.make("environment"); + const uploading = (progress: number) => ({ + status: "uploading" as const, + environmentId, + progress, + }); + const ready = { status: "ready" as const, environmentId, attachmentId: "attachment" }; + // The first render reads the clock to schedule the badge. + using _timers = fakeTimers({ now: 0 }); + await using screen = await render(); + await expect.element(screen.getByText("hidden")).toBeInTheDocument(); + await act(() => vi.advanceTimersByTime(75)); + await screen.rerender(); + await act(() => vi.advanceTimersByTime(75)); + await expect.element(screen.getByText("hidden")).toBeInTheDocument(); + + await screen.rerender(); + await act(() => vi.advanceTimersByTime(149)); + await screen.rerender(); + await expect.element(screen.getByText("hidden")).toBeInTheDocument(); + await act(() => vi.advanceTimersByTime(1)); + await expect.element(screen.getByText("45%")).toBeInTheDocument(); + await screen.rerender(); + await expect.element(screen.getByText("70%")).toBeInTheDocument(); + await screen.rerender(); + await expect.element(screen.getByText("hidden")).toBeInTheDocument(); + + await screen.rerender(); + await expect.element(screen.getByText("hidden")).toBeInTheDocument(); + await act(() => vi.advanceTimersByTime(150)); + await expect.element(screen.getByText("0%")).toBeInTheDocument(); + await screen.rerender(); + await expect.element(screen.getByText("hidden")).toBeInTheDocument(); +}); diff --git a/apps/web/src/hooks/useAttachmentUploadProgress.ts b/apps/web/src/hooks/useAttachmentUploadProgress.ts new file mode 100644 index 0000000000000..bc320b9eed6df --- /dev/null +++ b/apps/web/src/hooks/useAttachmentUploadProgress.ts @@ -0,0 +1,18 @@ +import { + type AttachmentUploadState, + formatAttachmentUploadProgress, +} from "../lib/attachmentUploadState"; +import { useDelayedStatus } from "./useDelayedStatus"; + +export function useAttachmentUploadProgress( + key: string, + upload: AttachmentUploadState | undefined, +) { + const uploading = upload?.status === "uploading"; + const shown = useDelayedStatus( + `${key}:${upload?.environmentId ?? ""}:${uploading ? "uploading" : "idle"}`, + uploading ? true : null, + 150, + ); + return uploading && shown ? formatAttachmentUploadProgress(upload.progress) : null; +} diff --git a/apps/web/src/hooks/useDelayedStatus.ts b/apps/web/src/hooks/useDelayedStatus.ts index 718c0f03538e3..d9a4c15c695c5 100644 --- a/apps/web/src/hooks/useDelayedStatus.ts +++ b/apps/web/src/hooks/useDelayedStatus.ts @@ -7,12 +7,12 @@ import { useState } from "react"; import { useDeadlinePassed } from "./useDeadline"; -export function useDelayedStatus(key: string, value: A | null): A | null { +export function useDelayedStatus(key: string, value: A | null, showDelayMs?: number): A | null { const [state, setState] = useState(() => - stepDelayedStatus(null, { key, value, deadlinePassed: false }, Date.now), + stepDelayedStatus(null, { key, value, deadlinePassed: false, showDelayMs }, Date.now), ); const deadlinePassed = useDeadlinePassed(delayedStatusDeadline(state)); - const next = stepDelayedStatus(state, { key, value, deadlinePassed }, Date.now); + const next = stepDelayedStatus(state, { key, value, deadlinePassed, showDelayMs }, Date.now); if (next !== state) setState(next); return shownDelayedStatus(next, key); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b24265d02197e..40daa0da7dcc8 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2012,6 +2012,10 @@ code { list-style-type: lower-roman; } +.chat-markdown.user-message-markdown ol { + list-style-type: decimal; +} + .chat-markdown li + li { margin-top: 0.25rem; } diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index 079c52df5d573..a8d4cfcc1d549 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -96,6 +96,7 @@ function stubCanvasPipeline( const supportsWebp = options?.supportsWebp ?? true; const close = vi.fn(); const fillRect = vi.fn(); + const canvasSizes: Array<{ width: number; height: number }> = []; vi.stubGlobal( "createImageBitmap", vi.fn(async () => ({ width: 4000, height: 3000, close })), @@ -106,7 +107,9 @@ function stubCanvasPipeline( constructor( public width: number, public height: number, - ) {} + ) { + canvasSizes.push({ width, height }); + } getContext() { return { fillStyle: "", @@ -131,7 +134,7 @@ function stubCanvasPipeline( vi.stubGlobal("HTMLCanvasElement", HtmlCanvas); vi.stubGlobal("document", { createElement: () => new HtmlCanvas(0, 0) }); } - return { close, fillRect }; + return { close, fillRect, canvasSizes }; } afterEach(() => { @@ -263,22 +266,57 @@ describe("composer image thumbnails", () => { }); describe("HEIC attachment preparation", () => { + it("shares the native decode and shows its thumbnail before JPEG encoding finishes", async () => { + const { close } = stubCanvasPipeline(() => 3); + let finishEncoding!: (blob: Blob) => void; + const jpeg = new Promise((resolve) => { + finishEncoding = resolve; + }); + const Canvas = globalThis.OffscreenCanvas; + vi.stubGlobal( + "OffscreenCanvas", + class extends Canvas { + override convertToBlob(options?: ImageEncodeOptions) { + return options?.type === "image/jpeg" ? jpeg : super.convertToBlob(options); + } + }, + ); + const original = makeHeicFile(); + const preparation = prepareImageForAttachment(original, 1024); + const queued = prepareImageForAttachment(makeHeicFile({ name: "next.heic" }), 1024); + const results = Promise.all([preparation, queued]); + try { + const thumbnail = await createComposerImageThumbnail(original); + expect(thumbnail).toMatch(/^data:image\/png;base64,/); + expect(mocks.heicTo).not.toHaveBeenCalled(); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + expect(close).not.toHaveBeenCalled(); + } finally { + finishEncoding(new Blob([new Uint8Array(4)], { type: "image/jpeg" })); + await results; + } + const [result, next] = await results; + expect(result.ok && result.file.type).toBe("image/jpeg"); + expect(result.ok && result.file.size).toBe(4); + expect(next.ok).toBe(true); + expect(close).toHaveBeenCalledTimes(2); + }); + it("converts a HEIC photo with a missing MIME type into a named JPEG", async () => { const original = makeHeicFile({ name: "IMG_1234.HEIC", type: "", lastModified: 123, }); - mocks.heicTo.mockResolvedValueOnce( - new Blob([new Uint8Array([4, 5, 6, 7])], { type: "image/jpeg" }), - ); + const { close } = stubCanvasPipeline(() => 4); + vi.mocked(createImageBitmap).mockRejectedValue(new Error("HEIC is not supported natively")); + mocks.heicTo.mockResolvedValueOnce({ width: 4000, height: 3000, close }); const result = await prepareImageForAttachment(original, 1024); expect(mocks.heicTo).toHaveBeenCalledWith({ blob: original, - type: "image/jpeg", - quality: 0.92, + type: "bitmap", }); expect(result.ok && result.file.name).toBe("IMG_1234.jpg"); expect(result.ok && result.file.type).toBe("image/jpeg"); @@ -287,24 +325,26 @@ describe("HEIC attachment preparation", () => { expect(result.ok && result.recompressed).toBe(true); }); - it("compresses JPEG intermediates above the source safety ceiling", async () => { + it("resizes decoded HEIC pixels before encoding the JPEG", async () => { const original = makeHeicFile({ name: "large.heic", type: "image/heic", }); - mocks.heicTo.mockResolvedValueOnce( - new Blob([new Uint8Array(MAX_COMPRESSIBLE_SOURCE_BYTES + 1)], { - type: "image/jpeg", - }), - ); - const { close } = stubCanvasPipeline(() => 200_000); + const { close, canvasSizes } = stubCanvasPipeline(() => 200_000); + vi.mocked(createImageBitmap).mockRejectedValue(new Error("HEIC is not supported natively")); + mocks.heicTo.mockResolvedValueOnce({ width: 4000, height: 3000, close }); const result = await prepareImageForAttachment(original, 1_000_000); expect(result.ok && result.file.name).toBe("large.jpg"); expect(result.ok && result.file.type).toBe("image/jpeg"); expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000); - expect(close).toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + expect(canvasSizes).toEqual([ + { width: 2000, height: 1500 }, + { width: 256, height: 256 }, + ]); }); it("rejects oversized HEIC dimensions before loading the decoder", async () => { diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts index ed41b9ec45320..e174bebf4b9e4 100644 --- a/apps/web/src/lib/imageCompression.ts +++ b/apps/web/src/lib/imageCompression.ts @@ -240,11 +240,22 @@ export function inheritComposerImageThumbnail(source: File, target: File): void } async function renderComposerImageThumbnail(file: File): Promise { + if (isHeicImageFile(file)) return prepareHeicImage(file).thumbnail; // Placeholders thumbnail the uncompressed source, which can be big enough to OOM the decode. if (!canRecompress() || file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) return null; let bitmap: ImageBitmap | undefined; try { bitmap = await createImageBitmap(file); + return await renderComposerImageThumbnailFromBitmap(bitmap); + } catch { + return null; + } finally { + bitmap?.close(); + } +} + +async function renderComposerImageThumbnailFromBitmap(bitmap: ImageBitmap): Promise { + try { const side = Math.min(bitmap.width, bitmap.height); if (side <= 0) return null; const dimension = Math.min(256, side); @@ -265,8 +276,6 @@ async function renderComposerImageThumbnail(file: File): Promise return blob ? await readFileAsDataUrl(blob) : null; } catch { return null; - } finally { - bitmap?.close(); } } @@ -454,35 +463,90 @@ export async function compressImageToByteLimit( }; } -export async function prepareImageForAttachment( +const heicPreparations = new WeakMap< + File, + { image: Promise; thumbnail: Promise } +>(); +// Preparing tiles share full-size decodes, so keep only one HEIC bitmap alive at a time. +let heicPreparationQueue = Promise.resolve(); + +function prepareHeicImage(file: File) { + const cached = heicPreparations.get(file); + if (cached) return cached; + let resolveThumbnail!: (src: string | null) => void; + const thumbnail = new Promise((resolve) => { + resolveThumbnail = resolve; + }); + const image = heicPreparationQueue + .then(() => convertHeicImage(file, resolveThumbnail)) + .finally(() => resolveThumbnail(null)); + heicPreparationQueue = image.then( + () => undefined, + () => undefined, + ); + const preparation = { image, thumbnail }; + heicPreparations.set(file, preparation); + return preparation; +} + +async function convertHeicImage( file: File, - maxBytes: number, + onThumbnail: (src: string | null) => void, ): Promise { - if (!isHeicImageFile(file)) { - return compressImageToByteLimit(file, maxBytes); - } - if (file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) { return { ok: false, reason: "too-large" }; } - let converted: Blob; + let bitmap: ImageBitmap | undefined; try { const dimensionError = await validateHeicImageDimensions(file); if (dimensionError) { return { ok: false, reason: dimensionError }; } - const { heicTo } = await import("heic-to/csp"); - converted = await heicTo({ blob: file, type: "image/jpeg", quality: QUALITY_STEPS[0] }); + bitmap = + typeof createImageBitmap === "function" + ? await createImageBitmap(file).catch(() => undefined) + : undefined; + if (!bitmap) { + const { heicTo } = await import("heic-to/csp"); + bitmap = await heicTo({ blob: file, type: "bitmap" }); + } + const imageSize = fitImageAttachmentSize(bitmap) ?? { + width: bitmap.width, + height: bitmap.height, + }; + const surface = createCanvas(imageSize.width, imageSize.height); + if (!surface) return { ok: false, reason: "unreadable" }; + surface.context.drawImage(bitmap, 0, 0, imageSize.width, imageSize.height); + const [converted] = await Promise.all([ + encodeCanvas(surface.canvas, QUALITY_STEPS[0], "image/jpeg"), + renderComposerImageThumbnailFromBitmap(bitmap).then(onThumbnail), + ]); + if (!converted) return { ok: false, reason: "unreadable" }; + return { + ok: true, + file: new File([converted], fileNameForMimeType(file.name || "image", "image/jpeg"), { + type: "image/jpeg", + lastModified: file.lastModified, + }), + recompressed: true, + imageSize, + }; } catch { return { ok: false, reason: "unreadable" }; + } finally { + bitmap?.close(); } +} - const jpeg = new File([converted], fileNameForMimeType(file.name || "image", "image/jpeg"), { - type: "image/jpeg", - lastModified: file.lastModified, - }); - const result = await compressImageToByteLimit(jpeg, maxBytes, { +export async function prepareImageForAttachment( + file: File, + maxBytes: number, +): Promise { + if (!isHeicImageFile(file)) return compressImageToByteLimit(file, maxBytes); + const prepared = await prepareHeicImage(file).image; + if (!prepared.ok || prepared.file.size <= maxBytes) return prepared; + const result = await compressImageToByteLimit(prepared.file, maxBytes, { preferredMimeType: "image/jpeg", sourceSizeBytes: file.size, }); diff --git a/apps/web/src/lib/openPullRequestLink.browser.test.tsx b/apps/web/src/lib/openPullRequestLink.browser.test.tsx index a93f32a8902f7..7d93882b12b15 100644 --- a/apps/web/src/lib/openPullRequestLink.browser.test.tsx +++ b/apps/web/src/lib/openPullRequestLink.browser.test.tsx @@ -85,6 +85,8 @@ it.each([ ["shiftKey", commitUrl], ["altKey", commitUrl], ["shiftKey", pullRequestCommitUrl], + ["shiftKey", `${pullRequestCommitUrl}#diff-abcR5`], + ["altKey", `${pullRequestCommitUrl}#diff-abcR5`], ] as const)("keeps %s clicks on %s native", async (modifier, url) => { setup(); await using screen = await render( diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 31c04b6263e5f..74a0b29d7aa22 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,3 +1,4 @@ +import { parsePullRequestAnchor } from "@t3tools/shared/pullRequestAnchor"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; @@ -89,6 +90,7 @@ export function useOpenChangeRequestLink( }); if (target === null) return false; const { project, repository, number, host } = target; + const revealUrl = parsePullRequestAnchor(targetUrl) === null ? undefined : targetUrl; const pullRequestsSearch = { repository, number, @@ -106,6 +108,7 @@ export function useOpenChangeRequestLink( ...(host === undefined ? {} : { host }), repository, url: targetUrl, + ...(revealUrl === undefined ? {} : { revealUrl }), number, }); if (!resolvedThreadRef) { @@ -124,7 +127,12 @@ export function useOpenChangeRequestLink( } void navigate({ to: "/pull-requests", - search: { involvement: "all", state: "all", ...pullRequestsSearch }, + search: { + involvement: "all", + state: "all", + ...pullRequestsSearch, + ...(revealUrl === undefined ? {} : { selectedUrl: revealUrl }), + }, }); return true; }; @@ -140,6 +148,14 @@ export function useOpenChangeRequestLink( const parsedCommit = sourceControlClients.parseCommitUrl(targetUrl, identity?.provider); // Shift and Alt keep their browser meaning (new window, download) on commit links. if (parsedCommit !== null && (event.shiftKey || event.altKey)) return false; + // A plain commit link stays a workspace commit; one naming a file or line opens the PR's diff. + const anchor = parsePullRequestAnchor(targetUrl); + if (anchor !== null && anchor.kind !== "code" && openPullRequest()) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + if ( parsedCommit !== null && commitThread && diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index ac7afbc8145ac..f389b686742a7 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -197,6 +197,18 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); + it("preserves explicit ordered-list values and continues from resets when copying", () => { + const list = new FakeElement("OL", [], { start: "0" }).append( + new FakeElement("LI").append(new FakeText("A")), + new FakeElement("LI", [], { value: "5" }).append(new FakeText("B")), + new FakeElement("LI").append(new FakeText("C")), + new FakeElement("LI", [], { value: "0" }).append(new FakeText("D")), + ); + expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(list)))).toBe( + "0. A\n5. B\n6. C\n0. D", + ); + }); + it("keeps fences when a bare list item sits alongside the code block", () => { const container = new FakeElement("DIV").append( new FakeElement("UL").append(new FakeElement("LI")), diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 2364a6c77c4e5..cf55bb8101ae3 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -144,10 +144,17 @@ function serializeListItem( } function serializeList(list: Element, ordered: boolean, inTableCell: boolean): string { - const start = Number.parseInt(list.getAttribute("start") ?? "1", 10) || 1; + const start = Number.parseInt(list.getAttribute("start") ?? "1", 10); const items = [...list.children].filter((child) => child.tagName === "LI"); if (items.length === 0) return ""; - return `${items.map((item, index) => serializeListItem(item, ordered, start + index, inTableCell)).join("\n")}\n\n`; + let next = Number.isNaN(start) ? 1 : start; + return `${items + .map((item) => { + const value = Number.parseInt(item.getAttribute("value") ?? "", 10); + if (ordered && !Number.isNaN(value)) next = value; + return serializeListItem(item, ordered, next++, inTableCell); + }) + .join("\n")}\n\n`; } function serializeBlockquote(quote: Element, inTableCell: boolean): string { diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index ec0f4e87c12e8..3a6bd304863b0 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1009,6 +1009,45 @@ describe("rightPanelStore", () => { selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA), ).toMatchObject({ url }); expect(state.surfaces[1]).not.toHaveProperty("url"); + expect(state.surfaces[0]).not.toHaveProperty("revealUrl"); + }); + + it("holds a clicked link until the panel settles it, and a later link replaces it", async () => { + const pr = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4909 }; + const id = pullRequestSurfaceId(pr); + const url = "https://github.com/pingdotgg/t3code/pull/4909"; + const surface = () => + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA); + const comment = (n: number) => `${url}#issuecomment-${n}`; + const store = useRightPanelStore.getState(); + store.openPullRequest(refA, { ...pr, url: comment(1), revealUrl: comment(1) }); + expect(surface()).toMatchObject({ url: comment(1), revealUrl: comment(1) }); + store.openPullRequest(refA, { ...pr, url }); + expect(surface()).not.toHaveProperty("revealUrl"); + store.openPullRequest(refA, { ...pr, url: comment(2), revealUrl: comment(2) }); + // A host read started for an earlier link settles only that link. + store.settlePullRequestReveal(refA, id, comment(1)); + expect(surface()).toMatchObject({ revealUrl: comment(2) }); + store.settlePullRequestReveal(refA, id, comment(2)); + expect(surface()).toMatchObject({ url: comment(2) }); + expect(surface()).not.toHaveProperty("revealUrl"); + // Reopening a closed tab hands back its surface, whose url still names the comment. + store.openPullRequest(refA, { ...pr, url: comment(2) }); + expect(surface()).not.toHaveProperty("revealUrl"); + store.openPullRequest(refA, { ...pr, url: comment(3), revealUrl: comment(3) }); + await reloadRightPanelStore(); + expect(surface()).toMatchObject({ url: comment(3) }); + expect(surface()).not.toHaveProperty("revealUrl"); + store.openPullRequest(refA, { ...pr, url: comment(4), revealUrl: comment(4) }); + store.closeSurface(refA, id); + await reopenClosedView(useClosedViewStore.getState().entries[0]!, { + workspaceAvailable: true, + openPreview: () => { + throw new Error("A PR tab must not create a browser session"); + }, + }); + expect(surface()).toMatchObject({ url: comment(4) }); + expect(surface()).not.toHaveProperty("revealUrl"); }); it("restores each environment, thread and PR surface's detail tab after reload and URL reopen", async () => { diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 1f736d4f119ae..7de186174d348 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -73,6 +73,8 @@ export type RightPanelSurface = repository: string; number: number; url?: string; + /** A clicked link waiting for the panel to show where it points; cleared once shown. */ + revealUrl?: string; detailTab?: PullRequestDetailTab; } | { id: "pull-requests"; kind: "pull-requests" } @@ -148,6 +150,8 @@ interface RightPanelStoreState { repository: string; number: number; url?: string; + /** A clicked link to reveal; reopening a surface passes only its `url`. */ + revealUrl?: string; detailTab?: PullRequestDetailTab; }, ) => void; @@ -156,6 +160,8 @@ interface RightPanelStoreState { surfaceId: string, detailTab: PullRequestDetailTab, ) => void; + /** Clears the surface's pending link only while it is still `revealUrl`. */ + settlePullRequestReveal: (ref: ScopedThreadRef, surfaceId: string, revealUrl: string) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( ref: ScopedThreadRef, @@ -293,6 +299,13 @@ export function pullRequestSurface(target: { }; } +/** A pending link belongs to the click that made it, not to a reload or a reopen. */ +const withoutReveal = (surface: RightPanelSurface): RightPanelSurface => { + if (surface.kind !== "pull-request" || surface.revealUrl === undefined) return surface; + const { revealUrl: _pending, ...rest } = surface; + return rest; +}; + const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, @@ -752,16 +765,17 @@ export const useRightPanelStore = create()( userAction(state, scopedThreadKey(ref), (current) => { const surface = pullRequestSurface(target); const next = upsertSurface(current, surface); - return target.url - ? { - ...next, - surfaces: next.surfaces.map((entry) => - entry.id === surface.id && entry.kind === "pull-request" - ? { ...entry, ...surface } - : entry, - ), - } - : next; + const { url, revealUrl } = target; + if (!url && !revealUrl) return next; + return { + ...next, + surfaces: next.surfaces.map((entry) => { + if (entry.id !== surface.id || entry.kind !== "pull-request") return entry; + // A later link replaces one not yet shown. + const { revealUrl: _previous, ...rest } = entry; + return { ...rest, ...surface, ...(revealUrl ? { revealUrl } : {}) }; + }), + }; }), ), setPullRequestDetailTab: (ref, surfaceId, detailTab) => @@ -781,6 +795,20 @@ export const useRightPanelStore = create()( ), })); }), + settlePullRequestReveal: (ref, surfaceId, revealUrl) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const existing = state.byThreadKey[threadKey]?.surfaces.find( + (surface) => surface.id === surfaceId, + ); + if (existing?.kind !== "pull-request" || existing.revealUrl !== revealUrl) return state; + return automaticUpdate(state, threadKey, (current) => ({ + ...current, + surfaces: current.surfaces.map((surface) => + surface.id === surfaceId ? withoutReveal(surface) : surface, + ), + })); + }), openFile: (ref, requestedPath, line) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { @@ -1164,8 +1192,15 @@ export const useRightPanelStore = create()( ), partialize: (state) => ({ byThreadKey: Object.fromEntries( - Object.entries(state.byThreadKey).filter( - ([threadKey]) => !isPullRequestsPanelKey(threadKey), + Object.entries(state.byThreadKey).flatMap(([threadKey, threadState]) => + isPullRequestsPanelKey(threadKey) + ? [] + : [ + [ + threadKey, + { ...threadState, surfaces: threadState.surfaces.map(withoutReveal) }, + ], + ], ), ), threadPanelVisibilityByThreadKey: Object.fromEntries( @@ -1195,7 +1230,7 @@ useRightPanelStore.subscribe((next, previous) => { ...removed.filter((surface) => surface.id !== before.activeSurfaceId), ...removed.filter((surface) => surface.id === before.activeSurfaceId), ]; - for (const surface of closedInOrder) { + for (const surface of closedInOrder.map(withoutReveal)) { if ( surface.kind === "terminal" || (surface.kind === "preview" && surface.resourceId !== null) diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index a95646488ed69..95b72f57992e9 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -29,6 +29,7 @@ function pullRequestSearchLabels(raw: unknown): Partial): PullRequestsSearch => ({ + ...(typeof raw.selectedUrl === "string" ? { selectedUrl: raw.selectedUrl.slice(0, 4096) } : {}), involvement: raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", state: diff --git a/apps/web/test/fixtures/usage-limits-hub/README.md b/apps/web/test/fixtures/usage-limits-hub/README.md index 102f8a31e573e..ec33b471b9ca7 100644 --- a/apps/web/test/fixtures/usage-limits-hub/README.md +++ b/apps/web/test/fixtures/usage-limits-hub/README.md @@ -2,15 +2,18 @@ A fake CLIProxyAPI hub that serves the Limits evidence scenarios in `scenarios.json`: -| Scenario | Accounts | -| -------- | -------------------------------------------------------------- | -| A | mixed Codex and Claude accounts | -| B | one account per provider | -| C | six long-email Codex accounts | -| L | named accounts (no email, so segments show the auth-file name) | -| T | forty accounts, so pooled segments are 4px wide at 390px | - -Reset times are minute offsets from `n0`. +| Scenario | Accounts | +| -------- | ------------------------------------------------------------------------ | +| A | mixed Codex and Claude accounts | +| B | one account per provider | +| C | six long-email Codex accounts | +| F | xAI and Antigravity accounts that read, answer malformed, or are refused | +| L | named accounts (no email, so segments show the auth-file name) | +| P | one account per read hub provider, plus an unread Kiro account | +| T | forty accounts, so pooled segments are 4px wide at 390px | + +Reset times are minute offsets from `n0`. An account's `state` of `malformed` or `refused` makes +its usage unreadable, so Limits leaves it out. ## Rebuild a capture diff --git a/apps/web/test/fixtures/usage-limits-hub/hub.mjs b/apps/web/test/fixtures/usage-limits-hub/hub.mjs index a2ba8d7c2ef09..5d10671fd7f07 100644 --- a/apps/web/test/fixtures/usage-limits-hub/hub.mjs +++ b/apps/web/test/fixtures/usage-limits-hub/hub.mjs @@ -13,8 +13,29 @@ const MINUTE = 60_000; // A window is [usedPercent, minutesFromN0 | null]; null is unknown reset timing. const at = (n0, minutes) => (minutes === null ? null : n0 + minutes * MINUTE); +const iso = (n0, minutes) => + minutes === null ? undefined : new Date(at(n0, minutes)).toISOString(); const usage = (account, n0) => { const [session, weekly, fable] = account.windows; + if (account.provider === "xai") { + return { + config: { + creditUsagePercent: session[0], + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end: iso(n0, session[1]) }, + }, + }; + } + if (account.provider === "antigravity") { + const bucket = (bucketId, window, [used, minutes]) => ({ + bucketId, + window, + remainingFraction: 1 - used / 100, + resetTime: iso(n0, minutes), + }); + return { + buckets: [bucket("gemini-5h", "5h", session), bucket("gemini-weekly", "weekly", weekly)], + }; + } if (account.provider === "codex") { const window = ([used, minutes], seconds) => ({ used_percent: used, @@ -68,6 +89,7 @@ NodeHttp.createServer((request, response) => { auth_index: id, provider: account.provider, ...(account.email ? { email: account.email } : {}), + ...(account.project_id ? { project_id: account.project_id } : {}), })), }); } @@ -75,6 +97,13 @@ NodeHttp.createServer((request, response) => { const call = JSON.parse(raw); const account = accounts[call.auth_index]; if (!account) return json(response, { status_code: 404, body: "{}" }); + // "refused" fails every upstream call; "malformed" answers the usage call with the wrong shape. + if (account.state === "refused") { + return json(response, { status_code: 403, body: JSON.stringify({ error: "refused" }) }); + } + if (call.url.endsWith("/userinfo")) { + return json(response, { status_code: 200, body: JSON.stringify({ id: call.auth_index }) }); + } if (call.url.endsWith("/rate-limit-reset-credits")) { const credits = Array.from({ length: account.credits ?? 0 }, (_, index) => ({ id: `${call.auth_index}-credit-${index}`, @@ -84,7 +113,11 @@ NodeHttp.createServer((request, response) => { })); return json(response, { status_code: 200, body: JSON.stringify({ credits }) }); } - return json(response, { status_code: 200, body: JSON.stringify(usage(account, n0)) }); + const body = + account.state === "malformed" + ? { config: "malformed", buckets: "malformed" } + : usage(account, n0); + return json(response, { status_code: 200, body: JSON.stringify(body) }); } response.writeHead(404).end(); }); diff --git a/apps/web/test/fixtures/usage-limits-hub/scenarios.json b/apps/web/test/fixtures/usage-limits-hub/scenarios.json index 70e9be83f9a10..b1d267ad18e0a 100644 --- a/apps/web/test/fixtures/usage-limits-hub/scenarios.json +++ b/apps/web/test/fixtures/usage-limits-hub/scenarios.json @@ -512,5 +512,91 @@ [20, 4480] ] } + }, + "P": { + "c1": { + "provider": "codex", + "email": "c1.codex.owner@example.com", + "windows": [ + [30, 120], + [60, 7560.0] + ] + }, + "a1": { + "provider": "claude", + "email": "a1.claude.owner@example.com", + "windows": [ + [50, 150.0], + [10, 9273.6], + [0, 10140] + ] + }, + "x1": { + "provider": "xai", + "email": "x1.grok.owner@example.com", + "windows": [[35, 4320]] + }, + "g1": { + "provider": "antigravity", + "project_id": "fixture-project", + "email": "g1.antigravity.owner@example.com", + "windows": [ + [60, 200], + [25, 6000] + ] + }, + "k1": { + "provider": "kiro", + "email": "k1.kiro.owner@example.com", + "windows": [[10, 60]] + } + }, + "F": { + "x1": { + "provider": "xai", + "email": "x1.grok.owner@example.com", + "windows": [[35, 4320]] + }, + "x2": { + "provider": "xai", + "email": "x2.grok.owner@example.com", + "windows": [[35, 4320]], + "state": "malformed" + }, + "x3": { + "provider": "xai", + "email": "x3.grok.owner@example.com", + "windows": [[35, 4320]], + "state": "refused" + }, + "g1": { + "provider": "antigravity", + "project_id": "fixture-project", + "email": "g1.antigravity.owner@example.com", + "windows": [ + [60, 200], + [25, 6000] + ] + }, + "g2": { + "provider": "antigravity", + "project_id": "fixture-project", + "email": "g2.antigravity.owner@example.com", + "windows": [ + [60, 200], + [25, 6000] + ], + "state": "malformed" + }, + "g3": { + "provider": "antigravity", + "project_id": "fixture-project", + "email": "g3.antigravity.owner@example.com", + "windows": [ + [60, 200], + [25, 6000] + ], + "state": "refused" + } } } diff --git a/docs/README.md b/docs/README.md index 3c98c72238820..264d509d97642 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,6 +65,7 @@ source alone does not explain. Most code changes do not need an internal documen ### Runbooks - [Development and local builds](./operations/development.md) +- [Scenarios: real-app proof for a fix](./operations/scenarios.md) - [T3 Connect setup](./operations/connect-setup.md) - [Release](./operations/release.md) - [Observability](./operations/observability.md) diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 0c328c50317c5..4a348382ac47b 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -94,9 +94,15 @@ Unlinking on the linking side only forgets the token. `T3-Mode-Limit`, which the receiving `/mcp` only ever narrows the client's approved access with. A plan-mode agent therefore cannot start full-access work through a full-access link. -- **The token goes only to an address that proves it is the linked - environment**: its descriptor must report the linked environment id. A LAN - address that now belongs to another machine never receives it. +- **A descriptor's environment id filters addresses; it does not prove + identity.** The token goes only to an address that reports the linked id, so + a LAN address that now reports another machine's never receives it. Anything can + report any id, though, so only addresses from the user's client count as a + machine's, and never a loopback one, which names whichever machine opens + it. An address an agent gives is recorded alone, and + [`linkRequestTarget`](../../packages/client-runtime/src/linkRequest.ts) names + a machine for it only when the client already reaches exactly one machine + there. An address alone never moves an unexpired link to another address. - **All callers on one side share the link's single session on the other**, so idempotency keys are hashed with the caller's namespace before they leave. Without that, two agents reusing a key would collide on the far side. diff --git a/docs/internals/voice-input.md b/docs/internals/voice-input.md index e381debdb792b..00964ab4ae332 100644 --- a/docs/internals/voice-input.md +++ b/docs/internals/voice-input.md @@ -63,6 +63,9 @@ at a time. Batch uploads carry their options in the body framing defined in [shared/speech.ts](../../packages/shared/src/speech.ts); streaming sends them in the socket's first command. Model files and the selected model, hardware placement, and unload timeout are environment state in server settings. +A model file from another app is installed as a symlink only after its SHA-256 +matches a catalog entry, so the store never loads a file the catalog does not pin +and removing a model never deletes a file T3 Code did not download. The Silero silence detector is optional. If its download fails, recordings use raw audio during the retry cooldown so a detector outage cannot block dictation. diff --git a/docs/operations/scenarios.md b/docs/operations/scenarios.md new file mode 100644 index 0000000000000..c49f23a4b2068 --- /dev/null +++ b/docs/operations/scenarios.md @@ -0,0 +1,173 @@ +# Scenarios + +A scenario is an [e2e](https://e2e.com/docs) test that shows one behavior in the real app: a real +server on its own T3 home, real HTTP and WebSocket, and a real browser on the web client or a +pooled iPhone simulator running the mobile app. The +dev replay agent stands in for the model, so a run is free and repeatable. Use one as the proof +for a bug fix: it fails on the base commit and passes on the fix. + +Scenarios live in [`scripts/scenarios`](../../scripts/scenarios) as `*.e2e.ts`. e2e is the +runner; its docs ship in `scripts/node_modules/e2e/docs` and `e2e guide` prints them. + +## Write one + +```ts +import { expect } from "e2e"; + +import { test, WEB } from "./t3.ts"; + +test( + "Tab moves from the add-project Git URL field to its submit button", + WEB, + async ({ t3, app, screen, browser }) => { + await t3.project("demo"); + await t3.open(); + + await screen.getByRole("button", "Add project").tap(); + await screen.getByText("Git URL").tap(); + await screen.getByRole("combobox").fill("https://github.com/pingdotgg/t3code.git"); + const submit = screen.getByRole("button", "Continue (Enter)"); + await expect(submit).toBeEnabled(); + + await browser.keyboard.press("Tab"); + await app.screenshot("after-tab"); + await expect(submit).toBeFocused(); + }, +); +``` + +`screen`, `browser`, `app`, and `expect` are e2e's own. [`t3.ts`](../../scripts/scenarios/t3.ts) +adds one fixture for what e2e cannot know about T3 Code: + +- `t3.project(title, files)` creates a git repository and adds it as a project. +- `t3.thread({ project, title, messages })` starts a thread and plays one replayed turn per + message before returning, so the thread has real history. `model` picks the recording. +- `t3.open(path)` opens the app, already signed in. + +State is created through the app's own CLI, so it is real events and works on a base commit +whose schema differs. New threads default to the replay agent's `showcase` recording: type a +message and send it. To play something else, add a recording to the +[replay agent](development.md#replay-provider). + +Write one test per file: a proof runs the file and needs exactly one test in it. + +Take an `app.screenshot("label")` where the bug shows. The proof pairs base and head captures by +label. e2e adds a capture at the point of failure itself. + +Write only `expect` assertions for the thing the bug breaks. A proof counts a failed assertion as +the bug. A control that cannot be found, or an app that does not start, counts as a scenario that +could not run. + +Keep `agent.act` and `agent.assert` out of scenarios: they call a model, and no model is +configured. + +## Run + +```sh +vp run scenarios # all of them +cd scripts && pnpm exec e2e run scenarios/replay-turn-streams.e2e.ts --headed +``` + +e2e starts the dev stack through [`start-app.ts`](../../scripts/scenarios/start-app.ts) on a free +port, runs the tests, and stops it. Results land in `scripts/.e2e`: `report.json`, `summary.md`, +and per test a `trace.md`, its screenshots, and a video. `vp run scenarios --target web` runs one +target; flags go straight after the script name, without `--`. + +The first run downloads e2e's browser (about 120 MB). To fetch it ahead of time, such as in a +machine image, run this from the repository root after `vp i`: + +```sh +node scripts/node_modules/playwright-core/cli.js install --only-shell chromium ffmpeg +``` + +e2e opens the web app at `127.0.0.1`, and the start script tells the web dev server to bind that +address. It does not use `localhost`, which some machines resolve to `::1` first. + +`e2e run --help` lists the rest: `--grep`, `--watch`, `--last-failed`, `--changed-since`. + +Run one at a time per checkout: a run recreates the checkout's scenario T3 home and its `.e2e` +output. Use a second worktree for a second run. + +## Prove a fix + +```sh +vp run scenarios:prove scripts/scenarios/add-project-tab-reaches-submit.e2e.ts \ + --base e509db3a2f^ --head e509db3a2f --upload-command "ghdrop upload" +``` + +[`prove-scenario.ts`](../../scripts/prove-scenario.ts) runs `e2e run` for that scenario on both +commits and compares the two reports. It prints, and writes to `scripts/.e2e/prove/proof.json`, +`failsOnBase`, `passesOnHead`, `baseSha`, `headSha`, `verdict`, each side's `steps`, `captures`, +`videos`, and `markdown`: a table with base and head captures side by side, each labelled with +its commit, ready for a pull request description. `--upload-command` names a command that takes +the capture paths and prints one URL per line. `--head` defaults to `HEAD`. + +It exits 0 only when the scenario fails on base and passes on head, 1 when it ran and did not +prove the fix, and 2 when it could not run. A scenario that passes on base does not reproduce the +bug, and the verdict says so. + +The scenario and e2e always come from your checkout. Only the app comes from the commit under +test: a temporary worktree beside your checkout, installed with `vp i` and removed afterwards. A +commit that is your clean `HEAD` runs in place. So a new scenario can prove a fix against a base +that predates it. + +## iOS + +An iOS scenario imports from [`t3-mobile.ts`](../../scripts/scenarios/t3-mobile.ts) and passes +`IOS` where a web one passes `WEB`: + +```ts +import { expect } from "e2e"; + +import { IOS, test } from "./t3-mobile.ts"; + +test( + "The iOS app lists a seeded thread and opens its history", + IOS, + async ({ t3, app, screen }) => { + const project = await t3.project("demo"); + await t3.thread({ project, title: "Earlier work", messages: ["first", "second"] }); + await t3.open(); + + const thread = screen.getByText("Earlier work", { exact: false }).first(); + await expect(thread).toBeVisible({ timeout: 120_000 }); + await thread.tap(); + await expect(screen.getByText("second fixture turn complete", { exact: false })).toBeVisible(); + }, +); +``` + +`t3.project` and `t3.thread` are the same. `t3.open()` installs the dev client for the checkout's +native fingerprint, loads it from Metro, and pairs it with the scenario's server. `device` is +e2e's [device fixture](https://e2e.com/docs/mobile#the-device-fixture) for system actions. + +The run leases an iPhone 18 Pro from the Simlock pool, so it needs `SIMLOCK_AGENT_DEVICE_URL` +and `AGENT_DEVICE_DAEMON_AUTH_TOKEN`, as `mobile:e2e` does, and a gateway on Simlock 0.7.0 or +later. A web-only run leases nothing. The device reaches this machine over the tailnet on ports +13990 (server) and 8190 (Metro); set `T3_SCENARIO_SERVER_PORT` and `T3_SCENARIO_METRO_PORT` when +another run on the machine holds them. An iOS run takes two to three minutes, most of it the +device and the first bundle. `e2e run --target web` or `--target ios` runs one side. + +`scenarios:prove` works the same for an iOS scenario. Each commit gets the dev client built for +its own native code. + +## Other tiers + +- **Android.** Add a target with `mobile({ platform: "android", device: simlock("Pixel 9") })` + and open the dev client with the Android form of the Expo link. Not built. +- **Live provider.** Point the default model in `start-app.ts` at a real provider's cheapest + model. Assertions on exact reply text would not hold. + +## In CI + +No job runs scenarios, and none should run all of them on every pull request. e2e can select the +ones a branch touches with `e2e run --changed-since origin/main` once +[test filters](https://e2e.com/docs/filters) are configured, and `@e2e-dev/github` posts the +result as a pull request comment. + +## What a scenario cannot cover + +- Desktop shell behavior (menus, windows, IPC), and Android. +- Provider-specific behavior. The replay agent speaks generic ACP. +- State no command can create, such as a run stopped by a usage limit. +- Timing. Replay streaming is simulated, so a scenario cannot show a performance regression. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 75910d9405c39..a510ff0008c55 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -333,14 +333,17 @@ off, its result arrives once it is back on. On the machine you will work from, open **Settings → Environments → Linked environments**, choose **Link**, and pick the other machine or enter its address. The list holds the machines this app knows and those on your T3 Connect account. If this -app is connected to that machine with a session that manages access there, it -makes the pairing code itself; otherwise paste one from that machine's -**Settings → Sharing & access**. From a terminal, use +app has an address for that machine and is connected to it with a session that +manages access there, it makes the pairing code itself; otherwise paste one +from that machine's **Settings → Sharing & access**. An address you type always +needs a pasted code. From a terminal, use `t3 environment link
    --pairing-code --access `. You can also ask an agent to link a machine by name, such as "delegate this to my vps". It shows a card in the thread where you pick the machine the same way -and the access, then approve. +and the access, then approve. An address the agent gives that this app does +not know for one of your machines also needs a pasted code. Paste one only for +an address you trust: the code is sent there. - The access you pick is the most agents from this machine may do there. An agent also never gets more there than its own mode here. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 39731cb3d35b6..e9edbb449dd89 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -194,6 +194,10 @@ Where you can edit a description, you can also tick its checkboxes from the Summ (the Overview tab on mobile). GitLab calls these merge requests. +A link to part of a pull request opens that part: a comment or review is marked in the activity, a +changed-line link opens the file at those lines, a checks link opens the checks, and a commit link +opens that commit's changes. On web and desktop, hovering a comment or commit link previews it. + On mobile, **Pull requests** lists reviews across your connected environments. Open a review to read its description, activity, commits, and changed files. Line comments stay as drafts until you submit the review. **Work on PR** opens a chat with the review attached; sending diff --git a/docs/user/usage.md b/docs/user/usage.md index d7e0dad901bef..e6b3a46d5a14e 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -165,6 +165,9 @@ Grok reports the remaining subscription allowance and reset time for its current after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication or endpoint configurations do not report subscription limits. +Muse Code reports its session and weekly windows and its tier once the Muse CLI has run in a thread. +Until then it shows no limits. Readings are not kept across a server restart. + API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`. @@ -173,7 +176,8 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`. To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the environment that will connect to the hub and enter its URL and management key. -The accounts appear under **Usage → Limits**. Codex accounts show banked reset credits; select an +The hub's Codex, Claude, xAI (shown as Grok) and Antigravity accounts appear under **Usage → Limits**; +accounts for other providers are not read. Codex accounts show banked reset credits; select an account and choose **Use reset** to redeem one. No hub plugin is required. This connection supplies usage information; configure diff --git a/docs/user/voice-input.md b/docs/user/voice-input.md index 7e5c36fa4d1eb..0672ae23ea233 100644 --- a/docs/user/voice-input.md +++ b/docs/user/voice-input.md @@ -54,6 +54,16 @@ isn’t already selected. **Test model** lets you try it without sending a messa If you use the composer microphone before a model is installed, it offers to download one. The recommendation uses your Voice language, or your system’s preferred languages with **Auto-detect**. +If another app already downloaded one of these models, you don’t need a second copy. +**Download** first looks in Handy’s model folder and the Hugging Face cache on that +environment and uses a copy it finds there instead of downloading. For a copy anywhere +else, on web or desktop, paste the path of the model file or its folder under the model +list and choose **Use existing**. The path is on the environment +that transcribes, not on the device you are using. T3 Code checks that the file is exactly a +model from its list and then uses it in place. Removing the model in T3 Code leaves your file +alone; if the other app deletes it, download or link the model again. Files in another +format, such as Core ML, ONNX, or MLX builds of the same model, can’t be used. + Select the languages you dictate in to compare models that support all of them. Accuracy shows how often the model got words or characters wrong in each language; speed is the time to transcribe 30 seconds on a reference GPU. Choose diff --git a/evals/mcp-tool-use/agent-device-help.txt b/evals/mcp-tool-use/agent-device-help.txt index a45698bfb2f04..058f29b5bf6a1 100644 --- a/evals/mcp-tool-use/agent-device-help.txt +++ b/evals/mcp-tool-use/agent-device-help.txt @@ -1,4 +1,4 @@ -agent-device 0.21.31 prints this help; apps/server/src/device/deviceToolVersions.json pins that version. Regenerate from +agent-device 0.21.36 prints this help; apps/server/src/device/deviceToolVersions.json pins that version. Regenerate from the repo root with: node scripts/node_modules/agent-device/bin/agent-device.mjs help [commands|record|screenshot|open|settings|fill] $ agent-device help @@ -54,9 +54,8 @@ Guides (agent-device help ): physical-device / ios-system-ui / maestro foldable iPhone Duo panels, the fold command, and why refs die on a pose change - $ agent-device help commands -agent-device 0.21.31 — commands +agent-device 0.21.36 — commands Full command catalog. Use agent-device help for exact flags and behavior. @@ -88,7 +87,7 @@ Commands: doctor Diagnose device, app, dev-server, and RN/Expo readiness events Read session event timeline fill [text] Replace text in a UI input - find [value] Find an element and act + find [action] [value] Find an element and act focus Focus input at screen coordinates fold [pose] Fold or unfold a foldable iPhone simulator or Android emulator gesture ... Run pan, fling, swipe, pinch, rotate, transform, tap, or drag gestures @@ -181,7 +180,6 @@ Related: agent-device help command-specific flags agent-device help manual-qa routine QA loop agent-device help workflow full automation reference - $ agent-device help record Usage: agent-device record start [path] [--scope ] [--fps ] [--quality ] [--hide-touches] | record stop | record contact-sheet [--out ] @@ -194,12 +192,11 @@ Command flags: --hide-touches Record: skip touch-overlay post-processing for faster raw benchmark videos --scope Record: app requires an active app session; device/system records the whole screen --out Output path - $ agent-device help screenshot Usage: agent-device screenshot [path] [--out ] [--overlay-refs] [--crop-on ] [--pixel-density ] [--fullscreen, --full, -f] [--scale <0.01-1>] [--no-stabilize] [--normalize-status-bar] -Capture a screenshot of the active app or web session. Choose the capture scope, density, size, or annotations through the corresponding input fields when needed. Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --scale to downscale, --crop-on to crop the capture to the frame the selector resolves on the same screen (currently iOS simulators, Android emulators, and CoreDevice physical iPhones), --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator or Android chrome. Physical iPhone crop verification covers portrait CoreDevice/devicectl captures only. XCTest, runner fallback, and physical iPad crops are refused; landscape iPhone crop is unverified. Android captures immediately by default; --normalize-status-bar also waits for transient UI to fade, and --no-stabilize keeps capture immediate. With the native macOS backend, the front window is the app's frontmost visible, nonempty normal-level (layer-0) window on the current Space: floating palettes and app-modal alerts at window level 8 are skipped, and a sheet is captured as its own window. On macOS, any --surface other than app (desktop, menubar, frontmost-app) always captures the main display and refuses an explicit --fullscreen. On single-display iOS simulators and Android devices, the result reports displayRotation, the rotation the display rendered the capture in, when the device returns a reading in time; otherwise the field is absent. +Capture a screenshot of the active app or web session. Choose the capture scope, density, size, or annotations through the corresponding input fields when needed. Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --scale to downscale, --crop-on to crop the capture to the frame the selector resolves on the same screen (currently iOS simulators, Android emulators, and CoreDevice physical iPhones), --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator or Android chrome. Physical iPhone crop verification covers portrait CoreDevice/devicectl captures only. XCTest, runner fallback, and physical iPad crops are refused; landscape iPhone crop is unverified. Android captures immediately by default; --normalize-status-bar also waits for transient UI to fade, and --no-stabilize keeps capture immediate. With the native macOS backend, the front window is the app's frontmost visible, nonempty normal-level (layer-0) window on the current Space: floating palettes and app-modal alerts at window level 8 are skipped, and a sheet is captured as its own window. On macOS, any --surface other than app (desktop, menubar, frontmost-app) always captures the main display and refuses an explicit --fullscreen. On single-display iOS simulators and Android devices, the result reports displayRotation, the rotation the display rendered the capture in, when the device returns a reading in time; otherwise the field is absent. On iOS simulators a screenshot waits for that simulator's status bar to be free, and a request its caller cancelled while it waits reports the cancellation instead of capturing. Command flags: --out Output path @@ -210,15 +207,17 @@ Command flags: --scale <0.01-1> Screenshot: resize both dimensions by this factor (or use AGENT_DEVICE_SCREENSHOT_SCALE) --no-stabilize Screenshot: capture immediately without Android status-bar stabilization (the default) --normalize-status-bar Screenshot: temporarily normalize iOS simulator or ADB-backed Android status-bar chrome for deterministic screenshot diffs; ADB-backed Android waits for transient UI to fade - $ agent-device help open Usage: - agent-device open [appOrUrl] [url] [--activity ] [--launch-console ] [--launch-args ] [--test-ime] [--no-test-ime] [--save-script [path]] [--force, --overwrite] [--no-record] [--relaunch] [--foreground] [--timeout ] [--wait ] [--surface app|frontmost-app|desktop|menubar] [--metro-host ] [--metro-port ] [--bundle-url ] [--launch-url ] + agent-device open [appOrUrl] [url] [--activity ] [--launch-console ] [--launch-args ] [--test-ime] [--no-test-ime] [--save-script [path]] [--force, --overwrite] [--no-record] [--relaunch] [--foreground] [--timeout ] [--wait ] [--surface app|frontmost-app|desktop|menubar] [--metro-host ] [--metro-port ] [--bundle-url ] [--launch-url ] [--os ] [--image-tag ] [--physical] -Boot the selected device when needed, then open an app, deep link, or URL in a session. Use the app or URL inputs to choose what becomes the foreground automation target. Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app's Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app's launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; in both cases inspect the warning and answer with alert get then alert accept; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app's target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session. +Boot the selected device when needed, then open an app, deep link, or URL in a session. Use the app or URL inputs to choose what becomes the foreground automation target. Use --platform to bind URL/deep-link opens to the target platform. For local iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app's Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app's launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; for url-owner-unresolved, answer the prompt with alert accept; for alert-unrecognized, read the alert with alert get first, because it can be the app's own alert (such as a permission prompt) rather than the launch confirmation, and answer with alert accept only when it is the launch confirmation; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app's target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. When the runner reports its main-thread timeout for the accept and the prompt is still up, open presses Open again, but only when the time the open request has left covers a whole press and the read that checks it. A recognized prompt still visible after a failed accept, or once that time is gone, also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. The prompt is recognized by the OS's own wording of its title and buttons in the Simulator's preferred languages; when the runner cannot read that wording, the prompt stays on screen as alert-unrecognized. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session. Command flags: --force, --overwrite Force replacement of existing state (connect: replace an active connection; --save-script: overwrite an existing target instead of refusing) + --os Proxy lease: OS version or range the backend picks the device by (Android: API level); refused without such a backend + --image-tag Proxy lease: Android system image tag the backend picks the device by; refused without such a backend + --physical Proxy lease: ask the backend for a physical device instead of a simulator or emulator; refused without such a backend --surface app|frontmost-app|desktop|menubar macOS session surface for open (defaults to app) --metro-host Session-scoped Metro/debug host hint --metro-port Session-scoped Metro/debug port hint; on an emulator/simulator the host defaults to the loopback alias, physical devices still need --metro-host @@ -227,7 +226,7 @@ Command flags: --test-ime open/test/replay: activate the headless Android test IME for deterministic Unicode text entry (default on for emulators; opt-in on real devices; on test/replay it applies to the sessions the flow opens) --no-test-ime open/test/replay: keep the real Android keyboard even on emulators (opt out of the headless test IME; on test/replay it applies to the sessions the flow opens) --activity Android launch activity (package/Class, .Class, or Class) or HarmonyOS ability name; not for URL opens - --launch-console open: capture the initial iOS simulator launch console window to a file + --launch-console open: capture the initial local iOS simulator launch console window to a file --launch-args open: repeatable launch argument forwarded verbatim to the platform launch command (iOS app process args; Android adb shell am start args). Linux and macOS reject the flag. --save-script [path] Arm evidence capture on open, publish the armed recording on close; close --save-script alone (without an armed open) is rejected — start with open --save-script, or use session save-script mid-session. Optional custom output path. --relaunch open: terminate app process before launching it @@ -235,13 +234,14 @@ Command flags: --no-record Do not record this action --timeout Boot/Open/Prepare: startup budget covering the device boot (and runner preparation for prepare). Replay/Snapshot/Test: maximum wall-clock time for the command or attempt. With --settle: the settle-wait deadline (default 10s) --wait Open: wait up to n ms for a device another session is holding before failing with DEVICE_IN_USE - $ agent-device help settings Usage: agent-device settings | settings location set | settings appearance | settings text-size [extra-small|small|medium|large|extra-large|extra-extra-large|extra-extra-extra-large|accessibility-medium|accessibility-large|accessibility-extra-large|accessibility-extra-extra-large|accessibility-extra-extra-extra-large] | settings | settings fingerprint | settings clear-app-state [app-id] | settings reset-keychain clear | settings permission [full|limited] | settings permission [--app ] Read or change supported operating-system settings, animation scales, appearance, or app permissions on the selected target. Platform support varies by setting and action. A bare readable setting reads the value the target holds. A target that does not serve a setting refuses it with UNSUPPORTED_OPERATION before anything changes. +On an iOS simulator, settings wifi and settings airplane wait for screenshots and other status bar writes. The request timeout includes this wait (90 seconds for a standalone CLI request). Once the write starts, its status bar command has a separate 10-second timeout. A request or write that times out may already have changed the bar; check the bar before relying on it. Cancelling while still queued guarantees nothing was written. + Settings: wifi: Android switches Wi-Fi with svc wifi; an Apple simulator overrides only its status bar. bluetooth: Android over adb turns its Bluetooth adapter on or off with svc bluetooth, from Android 8.1. It reports the bluetooth state the device holds afterwards, or fails with bluetooth-not-applied or bluetooth-unreadable. A device without an adapter refuses with android_bluetooth_adapter_missing, and an adapter on a build older than 8.1 with android_bluetooth_command_unavailable, before anything changes. --app is refused. @@ -260,12 +260,11 @@ Settings: Command flags: --app Target an app without opening it: doctor verifies an installed app by id or name; settings applies an app-scoped change (permission, iOS location) to a bundle id or package - $ agent-device help fill Usage: agent-device fill | fill <@ref|selector> | fill --text-stdin -Replace text in a UI input selected by snapshot ref, selector, or coordinates. Pass an empty text to clear the field. Inspect unconfirmed evidence and assert the expected value or resulting screen before continuing. Prefer refs or selectors after snapshot; use recordAs to keep sensitive text out of a recorded replay while sending it to the live app. Every positional after an @ref is the replacement text, so fill @e57 good morning enters "good morning"; quote the text when the shell must preserve exact whitespace. Clear a field with an empty text argument: fill @e57 "" (the argument must be present — fill @e57 alone is a missing argument, not a clear). When visible label text also matches a non-input element, constrain the target with editable=true, for example fill 'label="Email" editable=true' "qa@example.com". To keep a secret out of argv, omit the text and pipe it in: printf %s "$PASSWORD" | fill @e57 --text-stdin (max 64 KiB, one trailing newline removed). +Replace text in a UI input selected by snapshot ref, selector, or coordinates. Pass an empty text to clear the field. Inspect unconfirmed evidence and assert the expected value or resulting screen before continuing. Prefer refs or selectors after snapshot; use recordAs to keep sensitive text out of a recorded replay while sending it to the live app. Every positional after an @ref is the replacement text, so fill @e57 good morning enters "good morning"; quote the text when the shell must preserve exact whitespace. Clear a field with an empty text argument: fill @e57 "" (the argument must be present — fill @e57 alone is a missing argument, not a clear). When visible label text also matches a non-input element, constrain the target with editable=true, for example fill 'label="Email" editable=true' "qa@example.com". To keep a secret out of argv, omit the text and pipe it in: printf %s "$PASSWORD" | fill @e57 --text-stdin (max 64 KiB, one trailing newline removed). On web, a coordinate fill selects the clicked field in the page and refuses with COMMAND_FAILED and details.reason text_entry_focus_not_observed, before it sends any text or key, when the click did not reach an editable text field it can reach: a read-only, disabled, date, checkbox, or select control, a field in a cross-origin frame, a sandboxed frame without allow-same-origin, or a closed shadow root, or a click that missed. The click has already landed, so run snapshot -i again and fill the field it reports by ref (fill @e2 text); a selector fill reaches the same coordinate path and hits the same limits. Command flags: --delay-ms Delay between typed characters diff --git a/knip.jsonc b/knip.jsonc index ed4bf19129645..068c8cf84ab69 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -31,13 +31,17 @@ "linux-after-pack.ts", "mobile-behavior/run.ts", "mobile-native-client.ts", + // e2e loads its config and the scenarios by path, and starts the app script. + "e2e.config.ts", + "prove-scenario.ts", + "scenarios/*.ts", "setup-worktree.ts", "sign-macos.ts", "prepare-server-compile-cache.cjs", "smoke-desktop-app.ts", "verify-windows-desktop-artifact.ts", ], - "ignoreDependencies": ["agent-device", "esbuild"], + "ignoreDependencies": ["esbuild"], // The `t3` npm package's files: copied into the package, never imported. // knip-schemas.test.ts writes a throwaway project to .knip-test-*, which a // knip run beside it in check:changed must not report. diff --git a/package.json b/package.json index c43ef19c7b856..9017f1117394b 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "mobile:install": "node scripts/mobile-native-client.ts ensure", "mobile:behavior": "node scripts/mobile-behavior.ts", "mobile:e2e": "node scripts/mobile-e2e.ts", + "scenarios": "vp run --filter @t3tools/scripts scenarios", + "scenarios:prove": "node scripts/prove-scenario.ts", "test": "vp run -r test", "test:cached": "node scripts/test-cached.ts", "test:perf:v2-wire": "vp test run apps/server/src/orchestration-v2/WireProjection.test.ts apps/server/src/orchestration-v2/threadHistoryPaging.test.ts apps/server/src/orchestration-v2/ThreadStream.test.ts apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts apps/server/src/orchestration-v2/ShellStream.test.ts apps/server/src/orchestration-v2/CommandPolicy.test.ts apps/server/src/orchestration-v2/runtimeLayer.test.ts apps/server/src/ws.test.ts packages/contracts/src/orchestrationV2.test.ts packages/client-runtime/src/operations/commands.test.ts packages/client-runtime/src/operations/commands.performance.test.ts packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts packages/client-runtime/src/state/threadHistoryController.test.ts packages/client-runtime/src/state/threadHistoryMerge.test.ts packages/client-runtime/src/state/threads-sync.test.ts packages/client-runtime/src/state/shellReducer.test.ts", diff --git a/packages/client-runtime/src/linkRequest.test.ts b/packages/client-runtime/src/linkRequest.test.ts index 25647abe5aa27..dc8a1b94db9e9 100644 --- a/packages/client-runtime/src/linkRequest.test.ts +++ b/packages/client-runtime/src/linkRequest.test.ts @@ -8,12 +8,15 @@ import { BearerConnectionTarget, RelayConnectionTarget } from "./connection/mode import type { EnvironmentPresentation } from "./connection/presentation.ts"; import type { RelayDiscoveredEnvironment } from "./relay/discovery.ts"; import { + ANOTHER_ADDRESS, linkableMachines, linkedEnvironmentNames, linkRequestAnswerInput, linkRequestDisplay, linkRequestFailureMessage, linkRequestInitialMachine, + linkRequestTarget, + linkRequestTargetName, rankMachinesByHint, } from "./linkRequest.ts"; @@ -187,9 +190,10 @@ const discovered = ( describe("linkableMachines", () => { it("lists the other machines with reported addresses before saved ones, once each", () => { const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + // A loopback address would name the linking server itself, so it is no address of the VPS. const vps = presentation("environment-vps", "VPS", { - saved: ["https://vps.example.com", "https://vps.ts.net"], - reported: ["https://vps.ts.net"], + saved: ["https://vps.example.com", "https://vps.ts.net", "http://127.0.0.1:3773"], + reported: ["https://vps.ts.net", "http://localhost:3773"], }); const mini = presentation("environment-mini", "Mini", { connected: false }); expect( @@ -312,6 +316,24 @@ describe("linkableMachines", () => { urls: ["https://environment-mini.t3.example"], }); expect(listed[2]).toMatchObject({ machine: "server", linked: true }); + + // A Connect record that publishes only a loopback address gives the machine none. + const local = discovered("environment-local", "Local"); + const published = { + ...local, + environment: { + ...local.environment, + endpoint: { ...local.environment.endpoint, httpBaseUrl: "http://127.0.0.1:3773/" }, + }, + }; + expect( + linkableMachines({ + environments: [], + threadEnvironmentId: EnvironmentId.make("environment-here"), + links: [], + connect: new Map([["environment-local", published]]), + })[0]?.urls, + ).toEqual([]); }); }); @@ -392,6 +414,67 @@ describe("rankMachinesByHint", () => { }); }); +describe("linkRequestTarget", () => { + const box = { + environmentId: EnvironmentId.make("environment-box"), + label: "Box", + machine: "server", + connected: true, + connectOnly: false, + linked: false, + urls: ["http://10.0.0.2:3773", "https://box.t3connect.example/"], + } as const; + + it("takes a machine from an address the agent gave only where this app already reaches it", () => { + // Anything can answer at an address as the box, so an id beside one is not trusted. + const unfamiliar = { + url: "https://other.example", + environmentId: box.environmentId, + label: "Box", + }; + expect(linkRequestTarget(unfamiliar, [box], null)).toEqual({ kind: "address" }); + expect(linkRequestTarget(unfamiliar, [box], box.environmentId)).toEqual({ kind: "address" }); + expect(linkRequestTargetName(unfamiliar)).toBe("https://other.example"); + // A username in the address must not pass for the host the code goes to. + expect(linkRequestTargetName({ url: "https://box.t3connect.example@other.example/x" })).toBe( + "https://other.example", + ); + // A known address in another spelling is the box, linked at its own addresses only. + expect( + linkRequestTarget({ url: "https://BOX.t3connect.example:443/mcp" }, [box], null), + ).toEqual({ + kind: "machine", + environmentId: box.environmentId, + label: "Box", + urls: box.urls, + linked: false, + }); + expect(linkRequestTarget({ url: "http://box.t3connect.example" }, [box], null)).toEqual({ + kind: "address", + }); + expect(linkRequestTarget({ url: "not a url" }, [box], null)).toEqual({ kind: "address" }); + expect(linkRequestTarget({ url: "blob:https://box.t3connect.example/1" }, [box], null)).toEqual( + { kind: "address" }, + ); + // A private address two machines share names neither. + const mini = { ...box, environmentId: EnvironmentId.make("environment-mini") }; + expect(linkRequestTarget({ url: "http://10.0.0.2:3773" }, [box, mini], null)).toEqual({ + kind: "address", + }); + }); + + it("links the machine the agent named or the user picked, at the addresses this app has", () => { + expect(linkRequestTarget({ environmentId: box.environmentId }, [box], null)).toMatchObject({ + kind: "machine", + label: "Box", + urls: box.urls, + }); + expect(linkRequestTarget({}, [box], box.environmentId)).toMatchObject({ kind: "machine" }); + expect(linkRequestTarget({}, [box], ANOTHER_ADDRESS)).toEqual({ kind: "address" }); + expect(linkRequestTarget({}, [box], null)).toEqual({ kind: "none" }); + }); +}); + describe("linkRequestFailureMessage", () => { it("passes through only known user-facing errors", () => { expect( diff --git a/packages/client-runtime/src/linkRequest.ts b/packages/client-runtime/src/linkRequest.ts index c5d06ae5de51d..d1cf9e81d9f9e 100644 --- a/packages/client-runtime/src/linkRequest.ts +++ b/packages/client-runtime/src/linkRequest.ts @@ -8,6 +8,7 @@ import { resolveEnvironmentMachineKind, type ServerConfig, } from "@t3tools/contracts"; +import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification"; import * as Option from "effect/Option"; import type { EnvironmentPresentation } from "./connection/presentation.ts"; @@ -50,9 +51,26 @@ export const linkRequestDefaultAccess = ( item: Pick, ): AuthMcpClientAccess => item.requestedAccess ?? "approval-required"; -/** What the card calls the other environment: its own name, else the address asked for. */ -export const linkRequestTargetName = (item: Pick): string => - item.label?.trim() || item.url || "a machine"; +const parsedUrl = (url: string) => { + try { + return new URL(url); + } catch { + return undefined; + } +}; + +/** The http or https origin of an address: what the server links, and nothing else in it. */ +const originOf = (url: string) => { + const parsed = parsedUrl(url); + return parsed?.protocol === "http:" || parsed?.protocol === "https:" ? parsed.origin : undefined; +}; + +/** Where a link to `address` sends its pairing code, which a username or path in it can hide. */ +export const linkDestination = (address: string): string => originOf(address) ?? address.trim(); + +/** What the card calls the other environment: where the address asked for leads, never a name it answers with. */ +export const linkRequestTargetName = (item: Pick): string => + item.url === undefined ? "a machine" : linkDestination(item.url); /** A machine the user could link a thread's environment to. */ export interface LinkableMachine { @@ -68,11 +86,17 @@ export interface LinkableMachine { * Where the client knows it answers, most preferred first: the addresses it * reports, then its saved routes, then its T3 Connect address. The server * keeps only those that answer as this machine. Empty for a machine reached - * only over SSH, whose forward lives in this client. + * only over SSH or loopback, which name this client's device. */ readonly urls: ReadonlyArray; } +/** A loopback address names whichever machine opens it: the linking server, not this one. */ +const isLoopbackUrl = (url: string) => { + const hostname = parsedUrl(url)?.hostname; + return hostname !== undefined && isLocalLoopbackHost(hostname); +}; + /** * The catalog's machines other than the thread's own, in catalog order, then those only T3 * Connect knows. `links` are the thread environment's; an expired one no longer counts as @@ -114,7 +138,7 @@ export function linkableMachines(input: { ...routes, ...(connect === undefined ? [] : [connect.httpBaseUrl]), ]), - ], + ].filter((url) => !isLoopbackUrl(url)), }; }); const savedIds = new Set(saved.map((machine) => machine.environmentId)); @@ -135,7 +159,7 @@ export function linkableMachines(input: { connected: false, connectOnly: true, linked: linkedIds.has(environment.environmentId), - urls: [environment.endpoint.httpBaseUrl], + urls: [environment.endpoint.httpBaseUrl].filter((url) => !isLoopbackUrl(url)), }; }); return [...saved, ...connectOnly]; @@ -192,6 +216,59 @@ export function linkRequestInitialMachine( : null; } +/** The machine choice for linking by an address typed in. */ +export const ANOTHER_ADDRESS = "another-address"; + +/** + * What a link request links: a machine from the client's catalog, or an address. + * `urls` is empty when the client knows no direct address for the machine, + * so the user types one. + */ +export type LinkRequestTarget = + | { readonly kind: "none" } + | { + readonly kind: "machine"; + readonly environmentId: EnvironmentId; + readonly label: string; + readonly urls: ReadonlyArray; + readonly linked: boolean; + } + | { readonly kind: "address" }; + +/** + * Anything can answer at an address as any machine, so an address the agent + * gave is a machine only where this client already reaches that machine. Its + * pairing code is minted for, and sent to, the client's own addresses alone. + */ +export function linkRequestTarget( + item: Pick, + machines: ReadonlyArray, + choice: string | null, +): LinkRequestTarget { + const asked = item.url === undefined ? undefined : originOf(item.url); + // Two machines on separate networks can share a private address. + const [reached, ...others] = + asked === undefined + ? [] + : machines.filter((machine) => machine.urls.some((url) => originOf(url) === asked)); + const chosen = + item.url === undefined + ? (item.environmentId ?? choice) + : reached !== undefined && others.length === 0 + ? reached.environmentId + : ANOTHER_ADDRESS; + if (chosen === null) return { kind: "none" }; + if (chosen === ANOTHER_ADDRESS) return { kind: "address" }; + const machine = machines.find((candidate) => candidate.environmentId === chosen); + return { + kind: "machine", + environmentId: machine?.environmentId ?? (chosen as EnvironmentId), + label: machine?.label ?? linkRequestTargetName(item), + urls: machine?.urls ?? [], + linked: machine?.linked ?? false, + }; +} + /** What a link request card shows: the actions while pending, otherwise a one-line outcome. */ export type LinkRequestDisplay = | { readonly kind: "pending" } @@ -208,10 +285,7 @@ export type LinkRequestDisplay = * names an access level the way the client's access picker does. */ export function linkRequestDisplay( - item: Pick< - LinkRequestItem, - "linkStatus" | "label" | "url" | "linkedLabel" | "linkedAccess" | "failure" - >, + item: Pick, visibility: "local" | "inherited" | "synthetic", accessLabel: (access: AuthMcpClientAccess) => string, ): LinkRequestDisplay { diff --git a/packages/client-runtime/src/pullRequestQuery.test.ts b/packages/client-runtime/src/pullRequestQuery.test.ts index 33eddb7f1a6e7..7d73813f3733c 100644 --- a/packages/client-runtime/src/pullRequestQuery.test.ts +++ b/packages/client-runtime/src/pullRequestQuery.test.ts @@ -33,6 +33,7 @@ describe("reading qualifiers out of a typed query", () => { review: "changes-requested", checks: "failing", }); + expect(parsePullRequestQuery("-draft:true").filters).toEqual({ draft: "hide" }); }); it("leaves an unknown value and a stray colon as text, and reads an unknown key as a label", () => { @@ -53,6 +54,20 @@ describe("reading qualifiers out of a typed query", () => { ]); }); + // regression: #30131 a query pasted from github.com lists nothing + it("ignores GitHub's own search qualifiers instead of reading them as labels", () => { + expect( + parsePullRequestQuery( + "is:pr state:open archived:false repo:org/repo org:org user:alice sort:updated-desc -repo:org/fork -state:closed fix", + ), + ).toEqual({ text: "fix", filters: {} }); + }); + + it("sets no author when the query names more than one", () => { + expect(parsePullRequestQuery("author:alice author:bob").filters).toEqual({}); + expect(parsePullRequestQuery("author:me author:@me").filters).toEqual({ author: "me" }); + }); + it("cuts a typed query back to what the contract carries", () => { const many = Array.from({ length: 14 }, (_, index) => `l${index}`).join(","); const long = "x".repeat(240); diff --git a/packages/client-runtime/src/pullRequestQuery.ts b/packages/client-runtime/src/pullRequestQuery.ts index 9b333972d0fac..b80202c94b068 100644 --- a/packages/client-runtime/src/pullRequestQuery.ts +++ b/packages/client-runtime/src/pullRequestQuery.ts @@ -29,6 +29,11 @@ function splitQualifierList(raw: string): string[] { .filter((part) => part.length > 0); } +/** `me` and `@me` both name the viewer; logins are case-insensitive. */ +function authorKey(login: string): string { + return login.toLowerCase().replace(/^@me$/, "me"); +} + function boundedNames(names: ReadonlyArray): string[] { return names .slice(0, MAX_QUALIFIER_VALUES) @@ -43,7 +48,8 @@ export function parsePullRequestQuery(raw: string): { const text: string[] = []; const labels: string[][] = []; const excludedLabels: string[] = []; - let author: string | undefined; + // `null` once two different logins are named: GitHub ORs them, the contract carries one. + let author: string | null | undefined; let draft: PullRequestListFilters["draft"]; let review: PullRequestListFilters["review"]; let checks: PullRequestListFilters["checks"]; @@ -59,13 +65,16 @@ export function parsePullRequestQuery(raw: string): { else labels.push(names); continue; } - case "author": + case "author": { if (negated) break; - author = value.slice(0, MAX_QUALIFIER_LENGTH).trim(); + const login = value.slice(0, MAX_QUALIFIER_LENGTH).trim(); + if (author === undefined) author = login; + else if (author === null || authorKey(author) !== authorKey(login)) author = null; continue; + } case "draft": - if (negated || (value.toLowerCase() !== "true" && value.toLowerCase() !== "false")) break; - draft = value.toLowerCase() === "true" ? "only" : "hide"; + if (value.toLowerCase() !== "true" && value.toLowerCase() !== "false") break; + draft = (value.toLowerCase() === "true") !== negated ? "only" : "hide"; continue; case "review": { const decision = negated ? undefined : REVIEW_VALUES[value.toLowerCase()]; @@ -80,6 +89,15 @@ export function parsePullRequestQuery(raw: string): { checks = state; continue; } + // GitHub's own search qualifiers, not labels: the list sets scope and order itself. + case "is": + case "archived": + case "state": + case "repo": + case "org": + case "user": + case "sort": + continue; case "": break; default: @@ -104,7 +122,7 @@ export function parsePullRequestQuery(raw: string): { ...(excludedLabels.length === 0 ? {} : { excludedLabels: excludedLabels.slice(0, MAX_QUALIFIER_VALUES) }), - ...(author === undefined ? {} : { author }), + ...(typeof author === "string" ? { author } : {}), ...(draft === undefined ? {} : { draft }), ...(review === undefined ? {} : { review }), ...(checks === undefined ? {} : { checks }), diff --git a/packages/client-runtime/src/voice-input/environment.ts b/packages/client-runtime/src/voice-input/environment.ts index 52fa5a0d1fe5b..a8ea541f75133 100644 --- a/packages/client-runtime/src/voice-input/environment.ts +++ b/packages/client-runtime/src/voice-input/environment.ts @@ -292,6 +292,17 @@ export const updateEnvironmentSpeechModelUnloadTimeout = ( client.updateModelUnloadTimeout({ headers, payload: { timeout } }), }); +export const linkEnvironmentSpeechModels = (prepared: PreparedConnection, path: string) => + request({ + group: "voice", + prepared, + method: "POST", + path: (urls) => urls.linkModels(), + // Hashing several multi-gigabyte files takes longer than an ordinary request. + timeoutMs: INFERENCE_OR_DOWNLOAD_TIMEOUT_MS, + run: ({ client, headers }) => client.linkModels({ headers, payload: { path } }), + }); + export const removeEnvironmentSpeechModel = (prepared: PreparedConnection, modelId: string) => request({ group: "voice", diff --git a/packages/client-runtime/src/voice-input/index.ts b/packages/client-runtime/src/voice-input/index.ts index 5843267800f67..5283d64eb0dc3 100644 --- a/packages/client-runtime/src/voice-input/index.ts +++ b/packages/client-runtime/src/voice-input/index.ts @@ -21,6 +21,7 @@ export { transcribeCloudPcm, getEnvironmentSpeechStatus, getEnvironmentSpeechStreamUrl, + linkEnvironmentSpeechModels, postProcessEnvironmentTranscript, removeEnvironmentSpeechModel, selectEnvironmentSpeechModel, diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index db0bd2daae610..4bbae77fb922d 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -56,6 +56,7 @@ import { CloudSpeechModels, CloudSpeechProvider, EnvironmentSpeechAccelerationRequest, + EnvironmentSpeechModelLinkRequest, EnvironmentSpeechModelRequest, EnvironmentSpeechModels, EnvironmentSpeechModelUnloadTimeoutRequest, @@ -888,6 +889,14 @@ export class EnvironmentVoiceHttpApi extends HttpApiGroup.make("voice") error: [...EnvironmentScopedOperationErrors, EnvironmentRequestInvalidError], }).middleware(EnvironmentAuthenticatedAuth), ) + .add( + HttpApiEndpoint.post("linkModels", "/api/voice/models/link", { + headers: OptionalBearerHeaders, + payload: EnvironmentSpeechModelLinkRequest, + success: EnvironmentSpeechModels, + error: [...EnvironmentScopedOperationErrors, EnvironmentRequestInvalidError], + }).middleware(EnvironmentAuthenticatedAuth), + ) .add( HttpApiEndpoint.post("updateAcceleration", "/api/voice/acceleration", { headers: OptionalBearerHeaders, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index fc38ffd4da9bb..b1fd02b3d3a07 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1603,8 +1603,9 @@ const OrchestrationV2LinkRequestFields = { url: Schema.optional(TrimmedNonEmptyString), reason: Schema.String, /** - * The environment the agent named, or the one answering at `url`. Without - * either the user picks the machine in the card. + * The environment the agent named. Without it or `url` the user picks the + * machine in the card. Beside a `url`, this and `label` are what answered + * there on an older server: unauthenticated, so clients ignore both. */ environmentId: Schema.optional(EnvironmentId), label: Schema.optional(Schema.String), diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 5c4927d4a164e..67acbe0d35b05 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -20,11 +20,23 @@ export const ServerProviderUsageWindow = Schema.Struct({ }); export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; +/** One kind of banked reset, such as Claude's weekly reset or its Fable-only reset. */ +export const ServerProviderResetCreditOption = Schema.Struct({ + creditId: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + description: TrimmedNonEmptyString, + availableCount: NonNegativeInt, + expiresAt: Schema.optional(IsoDateTime), +}); +export type ServerProviderResetCreditOption = typeof ServerProviderResetCreditOption.Type; + export const ServerProviderResetCredits = Schema.Struct({ availableCount: NonNegativeInt, nextExpiresAt: Schema.optional(IsoDateTime), /** Pins hub redemption to the displayed credit, including retries from another client. */ nextCreditId: Schema.optional(TrimmedNonEmptyString), + /** Present when resets differ in what they refill; redeem one by its `creditId`. */ + options: Schema.optional(ForwardCompatibleArray(ServerProviderResetCreditOption)), }); export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; @@ -87,7 +99,10 @@ export type UsageLimitSourceConsumeResetCreditInput = typeof UsageLimitSourceConsumeResetCreditInput.Type; export const ProviderConsumeResetCreditInput = Schema.Union([ - Schema.Struct({ instanceId: ProviderInstanceId }), + Schema.Struct({ + instanceId: ProviderInstanceId, + creditId: Schema.optional(TrimmedNonEmptyString), + }), UsageLimitSourceConsumeResetCreditInput, ]); export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index f0f692da3f8da..fd449585180be 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -173,6 +173,8 @@ export const PullRequestThreadComment = Schema.Struct({ createdAt: IsoDateTime, editedAt: Schema.optional(Schema.NullOr(IsoDateTime)), url: Schema.NullOr(Schema.String), + /** The review that posted the comment, so a link to a review with no text finds its comments. */ + reviewUrl: Schema.optional(Schema.NullOr(Schema.String)), reactions: Schema.optional(Schema.Array(PullRequestReaction)), canEdit: Schema.optional(Schema.Boolean), }); diff --git a/packages/contracts/src/speech.ts b/packages/contracts/src/speech.ts index ac87096a24cce..ff3362f67fc0c 100644 --- a/packages/contracts/src/speech.ts +++ b/packages/contracts/src/speech.ts @@ -194,6 +194,8 @@ export const EnvironmentSpeechModel = Schema.Struct({ active: Schema.Boolean, state: EnvironmentSpeechModelState, downloaded: Schema.optionalKey(Schema.Finite), + /** The file outside T3 Code's model folder this model is used from. */ + linkedFrom: Schema.optionalKey(Schema.String), }); export type EnvironmentSpeechModel = typeof EnvironmentSpeechModel.Type; @@ -203,6 +205,10 @@ export const EnvironmentSpeechModels = Schema.Struct({ export type EnvironmentSpeechModels = typeof EnvironmentSpeechModels.Type; export const EnvironmentSpeechModelRequest = Schema.Struct({ modelId: SpeechModelId }); +/** A model file, or a folder to search for them, on the environment's own disk. */ +export const EnvironmentSpeechModelLinkRequest = Schema.Struct({ + path: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), +}); export const EnvironmentSpeechState = Schema.Literals(["missing-model", "ready", "transcribing"]); export type EnvironmentSpeechState = typeof EnvironmentSpeechState.Type; @@ -215,6 +221,7 @@ const SupportedSpeechStatus = Schema.Struct({ size: Schema.Finite, supportsStreaming: Schema.Boolean, supportsBufferedStreaming: Schema.optionalKey(Schema.Boolean), + supportsModelLinking: Schema.optionalKey(Schema.Boolean), supportsTranslation: Schema.Boolean, languages: Schema.optionalKey(Schema.Array(Schema.String)), supportsLanguageDetection: Schema.optionalKey(Schema.Boolean), diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index a1771e2e748e9..3fee55c6fbcab 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -45,10 +45,9 @@ export interface ProviderInstance { ) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; readonly invalidateCaches?: Effect.Effect; - readonly consumeResetCredit?: () => Effect.Effect< - ProviderConsumeResetCreditOutcome, - ProviderDriverError - >; + readonly consumeResetCredit?: ( + creditId: string | undefined, + ) => Effect.Effect; readonly orchestrationAdapter: ProviderAdapter.ProviderAdapterV2["Service"]; readonly textGeneration: ProviderTextGeneration; readonly auth?: ProviderAuthController; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 9df85130d6017..5295232d3b14c 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -609,10 +609,16 @@ describe("makeManagedServerProvider", () => { yield* provider.applyUsageLimits({ checkedAt: "2026-04-10T00:05:00.000Z", windows: [{ id: "seven_day", kind: "weekly", label: "Weekly", usedPercent: 25 }], + authLabel: "tier-1", + }); + yield* provider.applyUsageLimits({ + checkedAt: "2026-04-10T00:06:00.000Z", + windows: [], + authLabel: "tier-2", }); - yield* provider.applyUsageLimits({ checkedAt: "2026-04-10T00:06:00.000Z", windows: [] }); const [update] = Array.from(yield* Fiber.join(updatesFiber)); + assert.strictEqual(update?.auth.label, "tier-1"); assert.deepStrictEqual(update?.usageLimits, { checkedAt: "2026-04-10T00:05:00.000Z", windows: [ @@ -631,7 +637,10 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); - it.effect("keeps live usage windows across a failed probe and a stale enrichment", () => + it.effect.each([ + { name: "a live plan", clearPlan: false }, + { name: "a cleared plan", clearPlan: true }, + ])("keeps $name and usage across a failed probe and stale enrichment", ({ clearPlan }) => Effect.scoped( Effect.gen(function* () { const releaseEnrichment = yield* Deferred.make(); @@ -651,7 +660,7 @@ describe("makeManagedServerProvider", () => { count === 1 ? { ...refreshedSnapshot, - auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a" }, + auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a", label: "tier-1" }, usageLimits: probedLimits, } : { @@ -689,9 +698,18 @@ describe("makeManagedServerProvider", () => { } as const; yield* provider.applyUsageLimits({ checkedAt: "2026-04-10T00:00:02.000Z", + authLabel: "tier-2", windows: [liveWindow], }); + if (clearPlan) { + yield* provider.applyUsageLimits({ + checkedAt: "2026-04-10T00:00:02.000Z", + complete: true, + windows: [liveWindow], + }); + } + yield* Deferred.succeed(releaseEnrichment, undefined); const enriched = yield* Stream.take(provider.streamChanges, 1).pipe( Stream.runCollect, @@ -699,11 +717,13 @@ describe("makeManagedServerProvider", () => { ); assert.deepStrictEqual(enriched.models, enrichedSnapshot.models); assert.deepStrictEqual(enriched.usageLimits?.windows, [liveWindow]); + assert.strictEqual(enriched.auth.label, clearPlan ? undefined : "tier-2"); const refreshed = yield* provider.refresh; assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); assert.strictEqual(refreshed.auth.workspaceId, "ws-a"); + assert.strictEqual(refreshed.auth.label, clearPlan ? undefined : "tier-2"); const switched = yield* provider.refresh; assert.strictEqual(switched.auth.workspaceId, "ws-b"); @@ -712,6 +732,91 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(layerAlwaysRunTest)), ); + it.effect.each([ + { name: "an empty reading", hasUsage: true, clearPlan: false }, + { name: "no reading", hasUsage: false, clearPlan: false }, + { name: "an empty reading after clearing the plan", hasUsage: true, clearPlan: true }, + { name: "no reading after clearing the plan", hasUsage: false, clearPlan: true }, + ])("keeps the live plan and limits after $name", ({ hasUsage, clearPlan }) => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const count = yield* Ref.make(0); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + if ((yield* Ref.updateAndGet(count, (n) => n + 1)) > 1) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return { + ...refreshedSnapshot, + auth: { status: "authenticated", label: "tier-1" }, + ...(hasUsage + ? { usageLimits: { checkedAt: refreshedSnapshot.checkedAt, windows: [] } } + : {}), + } satisfies ServerProvider; + }), + }); + yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain); + const refresh = yield* provider.refresh.pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* provider.applyUsageLimits({ + checkedAt: "2026-04-10T00:00:04.000Z", + authLabel: "tier-2", + complete: true, + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 70 }], + }); + if (clearPlan) { + yield* provider.applyUsageLimits({ + checkedAt: "2026-04-10T00:00:04.000Z", + complete: true, + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 70 }], + }); + } + yield* Deferred.succeed(release, undefined); + const refreshed = yield* Fiber.join(refresh); + assert.strictEqual(refreshed.auth.label, clearPlan ? undefined : "tier-2"); + assert.strictEqual(refreshed.usageLimits?.windows[0]?.usedPercent, 70); + assert.strictEqual(refreshed.usageLimits?.unavailable, undefined); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + + it.effect("drops an earlier plan override after a complete reading without a label", () => + Effect.scoped( + Effect.gen(function* () { + const usageLimits = { + checkedAt: "2026-04-10T00:00:04.000Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 70 }], + } as const; + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.succeed({ ...refreshedSnapshot, usageLimits }), + refreshOnInterval: false, + }); + yield* Stream.take(provider.streamChanges, 1).pipe(Stream.runDrain); + yield* provider.applyUsageLimits({ ...usageLimits, complete: true, authLabel: "tier-2" }); + assert.strictEqual((yield* provider.refresh).auth.label, "tier-2"); + + yield* provider.applyUsageLimits({ ...usageLimits, complete: true }); + assert.strictEqual((yield* provider.getSnapshot).auth.label, undefined); + const refreshed = yield* provider.refresh; + assert.strictEqual(refreshed.auth.label, undefined); + assert.deepStrictEqual(refreshed.usageLimits, usageLimits); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect("keeps sparse usage unavailable after probe failure until a full probe recovers", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 9471be743f79d..39332d013e2ce 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -16,12 +16,18 @@ import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; import * as ProviderHost from "./ProviderHost.ts"; -import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./usageLimits.ts"; +import { + applyUsageLimitsUpdate, + makeUsageLimits, + resolveUsageLimitsAfterProbe, +} from "./usageLimits.ts"; import type { ManagedServerProvider } from "./snapshot.ts"; interface ProviderSnapshotState { readonly snapshot: ServerProvider; readonly enrichmentGeneration: number; + // null preserves an explicitly cleared plan across probes and enrichment. + readonly usageAuthLabel: string | null | undefined; } function withUsageLimits( @@ -72,6 +78,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const snapshotStateRef = yield* Ref.make({ snapshot: initialSnapshot, enrichmentGeneration: 0, + usageAuthLabel: undefined, }); const settingsRef = yield* Ref.make(initialSettings); const enrichmentFiberRef = yield* Ref.make | null>(null); @@ -86,7 +93,15 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return [null, state] as const; } // Enrichment must not revert usage updates newer than its input snapshot. - const merged = withUsageLimits(nextSnapshot, state.snapshot.usageLimits); + const merged = withUsageLimits( + state.usageAuthLabel === undefined + ? nextSnapshot + : { + ...nextSnapshot, + auth: { ...nextSnapshot.auth, label: state.usageAuthLabel ?? undefined }, + }, + state.snapshot.usageLimits, + ); if (Equal.equals(state.snapshot, merged)) { return [null, state] as const; } @@ -150,6 +165,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return state.snapshot; } + // A probe without usage must not erase a report published while its I/O was in flight. + const usageBeforeProbe = (yield* Ref.get(snapshotStateRef)).snapshot.usageLimits; const probedSnapshot = yield* input.checkProvider; const { snapshot: nextSnapshot, generation: nextGeneration } = yield* Ref.modify( snapshotStateRef, @@ -167,21 +184,35 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( probedSnapshot.auth.workspaceId !== previous.workspaceId); const usageLimits = switchedAccount ? probedSnapshot.usageLimits - : resolveUsageLimitsAfterProbe({ - published: state.snapshot.usageLimits, - probed: probedSnapshot.usageLimits, - }); + : probedSnapshot.usageLimits === undefined && + state.snapshot.usageLimits !== usageBeforeProbe + ? state.snapshot.usageLimits + : resolveUsageLimitsAfterProbe({ + published: state.snapshot.usageLimits, + probed: probedSnapshot.usageLimits, + }); const workspaceId = usageLimits === probedSnapshot.usageLimits ? undefined : state.snapshot.auth.workspaceId; + const usageAuthLabel = + !switchedAccount && usageLimits?.checkedAt === state.snapshot.usageLimits?.checkedAt + ? state.usageAuthLabel + : undefined; + const labeledProbe = + usageAuthLabel === undefined + ? probedSnapshot + : { + ...probedSnapshot, + auth: { ...probedSnapshot.auth, label: usageAuthLabel ?? undefined }, + }; const snapshot = withUsageLimits( workspaceId && !probedSnapshot.auth.workspaceId - ? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } } - : probedSnapshot, + ? { ...labeledProbe, auth: { ...labeledProbe.auth, workspaceId } } + : labeledProbe, usageLimits, ); return [ { snapshot, generation }, - { snapshot, enrichmentGeneration: generation }, + { snapshot, enrichmentGeneration: generation, usageAuthLabel }, ] as const; }, ); @@ -197,16 +228,34 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const applyUsageLimits: ManagedServerProvider["applyUsageLimits"] = Effect.fnUntraced( function* (update) { const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { - const usageLimits = applyUsageLimitsUpdate({ - previous: state.snapshot.usageLimits, - update, - checkedAt: update.checkedAt, - }); - if (usageLimits === state.snapshot.usageLimits) { + const complete = update.complete && update.windows.length > 0; + const usageLimits = complete + ? makeUsageLimits(update) + : applyUsageLimitsUpdate({ + previous: state.snapshot.usageLimits, + update, + checkedAt: update.checkedAt, + }); + const { auth } = state.snapshot; + const label = update.windows.length > 0 ? update.authLabel : undefined; + const relabel = + (label !== undefined || (complete && state.usageAuthLabel !== undefined)) && + label !== auth.label; + if (usageLimits === state.snapshot.usageLimits && !relabel) { return [null, state] as const; } - const snapshot = withUsageLimits(state.snapshot, usageLimits); - return [snapshot, { ...state, snapshot }] as const; + const snapshot = withUsageLimits( + relabel ? { ...state.snapshot, auth: { ...auth, label } } : state.snapshot, + usageLimits, + ); + return [ + snapshot, + { + ...state, + snapshot, + usageAuthLabel: label ?? (complete ? null : state.usageAuthLabel), + }, + ] as const; }); if (snapshotToPublish !== null) { yield* PubSub.publish(changesPubSub, snapshotToPublish); diff --git a/packages/provider-core/src/server/snapshot.ts b/packages/provider-core/src/server/snapshot.ts index 88f2c1f113848..6cb7ea15c5b10 100644 --- a/packages/provider-core/src/server/snapshot.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -7,6 +7,14 @@ import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "./maintenance.ts"; +/** authLabel names the plan, for drivers whose usage report carries it. */ +export type ProviderUsageLimitsReport = ProviderUsageLimitsUpdate & { + readonly checkedAt: string; + readonly authLabel?: string; + /** A full reading replaces sparse windows and clears a previous probe failure. */ + readonly complete?: boolean; +}; + export interface ManagedServerProvider { /** Cached; pass { fresh: true } before executing updates. */ readonly resolveMaintenance: (options?: { @@ -16,8 +24,6 @@ export interface ManagedServerProvider { readonly refresh: Effect.Effect; readonly refreshUsage?: Effect.Effect; readonly streamChanges: Stream.Stream; - /** Merge windows by ID; updates with no usable window leave the snapshot untouched. */ - readonly applyUsageLimits: ( - update: ProviderUsageLimitsUpdate & { readonly checkedAt: string }, - ) => Effect.Effect; + /** Merge windows by ID and set any authLabel; an update with no window leaves the snapshot untouched. */ + readonly applyUsageLimits: (update: ProviderUsageLimitsReport) => Effect.Effect; } diff --git a/packages/provider-core/src/server/textGeneration.ts b/packages/provider-core/src/server/textGeneration.ts index 62369d754abab..5e6600f31809d 100644 --- a/packages/provider-core/src/server/textGeneration.ts +++ b/packages/provider-core/src/server/textGeneration.ts @@ -2,6 +2,7 @@ import type { BranchNamingOptions, ChatAttachment, ModelSelection, + ProjectId, PullRequestReviewGuide, TextGenerationError, } from "@t3tools/contracts"; @@ -18,6 +19,7 @@ export interface CommitMessageGenerationInput { includeBranch?: boolean; policy?: TextGenerationPolicy | undefined; modelSelection: ModelSelection; + projectId?: ProjectId | null | undefined; } export interface CommitMessageGenerationResult { @@ -37,6 +39,7 @@ export interface PrContentGenerationInput { threadContext?: string | undefined; policy?: TextGenerationPolicy | undefined; modelSelection: ModelSelection; + projectId?: ProjectId | null | undefined; } export interface PrContentGenerationResult { @@ -50,6 +53,7 @@ export interface BranchNameGenerationInput { message: string; attachments?: ReadonlyArray | undefined; modelSelection: ModelSelection; + projectId?: ProjectId | null | undefined; } export interface BranchNameGenerationResult { @@ -65,6 +69,7 @@ export interface ThreadTitleGenerationInput { attachments?: ReadonlyArray | undefined; instructions?: string | undefined; modelSelection: ModelSelection; + projectId?: ProjectId | null | undefined; } export interface ThreadTitleGenerationResult { @@ -90,6 +95,7 @@ export interface ReviewGuideGenerationInput { instructions?: string | undefined; stack?: ReadonlyArray | undefined; modelSelection: ModelSelection; + projectId?: ProjectId | null | undefined; } export type ReviewGuideGenerationResult = Omit< diff --git a/packages/provider-grok/src/server.ts b/packages/provider-grok/src/server.ts index 858f66ac31c11..4837089cf86af 100644 --- a/packages/provider-grok/src/server.ts +++ b/packages/provider-grok/src/server.ts @@ -1,2 +1,7 @@ export { GrokDriver, type GrokDriverEnv } from "./server/driver.ts"; export { type GrokAdapterV2DriverEnv } from "./server/adapter.ts"; +export { + GROK_USAGE_URL, + GrokUsageResponse, + grokUsageResponseToLimits, +} from "./server/usageLimits.ts"; diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index c6ba583c139ce..5bf1ab4fda57f 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -24,7 +24,8 @@ const GrokCredentials = Schema.Record( }), ); const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); -const GrokUsageResponse = Schema.Struct({ +export const GROK_USAGE_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; +export const GrokUsageResponse = Schema.Struct({ config: Schema.optional( Schema.Struct({ creditUsagePercent: Schema.optional(Schema.Number), @@ -179,9 +180,7 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( const usageLimits = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const response = yield* client.execute( - HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe( - HttpClientRequest.bearerToken(token), - ), + HttpClientRequest.get(GROK_USAGE_URL).pipe(HttpClientRequest.bearerToken(token)), ); if (response.status === 403) { const { error } = yield* HttpClientResponse.schemaBodyJson(GrokErrorResponse)(response); diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index f903f457af17f..76b46715c6020 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -42,6 +42,7 @@ import type { MuseItem, MuseSubscriptionUsage } from "./protocol.ts"; import type { MuseSdkHost, MuseSdkHostOptions } from "./sdk.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderUsageLimitsReport } from "@t3tools/provider-core/server/snapshot"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./adapter.ts"; @@ -208,7 +209,7 @@ const makeHarness = Effect.fnUntraced(function* ( policy = runtimePolicy, overrides: Pick< MuseAdapterV2Options, - "createHost" | "nativeEventLogger" | "modelCatalog" | "continuationRequests" + "createHost" | "nativeEventLogger" | "modelCatalog" | "continuationRequests" | "onUsageLimits" > = {}, ) { let hostCount = 0; @@ -607,7 +608,16 @@ describe("MuseAdapterV2", () => { it.effect("stops at a Meta usage limit until every exhausted window resets", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); + const reports: Array = []; + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + runtimePolicy, + { onUsageLimits: (report) => Effect.sync(() => void reports.push(report)) }, + ); yield* fake.emitHostWide("usage/changed", { ...SUBSCRIPTION_USAGE, window: { ...SUBSCRIPTION_USAGE.window, usedPercent: 103 }, @@ -624,6 +634,10 @@ describe("MuseAdapterV2", () => { assert.strictEqual(terminal.threadDisposition, "reusable"); assert.strictEqual(terminal.failure?.class, "usage_limit"); assert.strictEqual(terminal.failure?.resetAt, "2026-10-08T13:19:17.000Z"); + assert.deepStrictEqual( + reports.map((report) => [report.authLabel, report.windows[0]?.usedPercent]), + [["tier-1", 100]], + ); assert.isTrue( harness.allEvents.some( (event) => @@ -685,7 +699,16 @@ describe("MuseAdapterV2", () => { it.effect("skips an unreadable usage report without ending the session", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); + const reports: Array = []; + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + runtimePolicy, + { onUsageLimits: (report) => Effect.sync(() => void reports.push(report)) }, + ); yield* fake.emitHostWide("usage/changed", { window: "not usage" }); yield* fake.emitHostWide("usage/changed", { ...SUBSCRIPTION_USAGE, @@ -700,6 +723,7 @@ describe("MuseAdapterV2", () => { const terminal = yield* harness.takeEvent("turn.terminal"); assert.strictEqual(terminal.failure?.class, "usage_limit"); assert.isUndefined(terminal.failure?.resetAt); + assert.deepStrictEqual(reports, []); assert.strictEqual(fake.closeCount(), 0); }).pipe(Effect.scoped, Effect.provide(testLayer)), ); diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index 82e1adcb42277..d4b58e806517d 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -63,7 +63,11 @@ import { museApprovalOptions, type MuseItem, } from "./protocol.ts"; -import { isMuseUsageLimitFailure, museUsageLimitResetAt } from "./usageLimits.ts"; +import { + isMuseUsageLimitFailure, + museUsageLimitResetAt, + museUsageToLimits, +} from "./usageLimits.ts"; import { createMuseSdkHostEffect, MuseSdkError, @@ -75,6 +79,7 @@ import { type MuseSdkHost, } from "./sdk.ts"; import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot"; import { providerMessageTextWithAttachmentPaths, isProviderNativeImageAttachment, @@ -202,6 +207,8 @@ export interface MuseAdapterV2Options { readonly createHost?: typeof createMuseSdkHost; readonly requestTimeoutMs?: number; readonly nativeEventLogger?: ProviderEventLoggers.EventNdjsonLogger; + /** Receives every account-wide usage report, whichever session's host sent it. */ + readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -1378,6 +1385,9 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( // usage/changed is account-wide and carries no session ID. if (method === "usage/changed") { latestSubscriptionUsage = yield* decode(MuseSubscriptionUsage, params); + if (options.onUsageLimits) { + yield* options.onUsageLimits(museUsageToLimits(latestSubscriptionUsage)); + } return; } if (params.sessionId !== nativeSessionId) return; diff --git a/packages/provider-muse/src/server/driver.test.ts b/packages/provider-muse/src/server/driver.test.ts new file mode 100644 index 0000000000000..7017b3f4e35a7 --- /dev/null +++ b/packages/provider-muse/src/server/driver.test.ts @@ -0,0 +1,216 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as Sink from "effect/Sink"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as HttpClient from "effect/http/HttpClient"; +import { vi } from "vite-plus/test"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as sdk from "./sdk.ts"; +import { MuseDriver } from "./driver.ts"; + +vi.mock("./sdk.ts", { spy: true }); + +const layerTest = TestProviderHost.layer({ + runBackgroundWork: false, + settings: { ...DEFAULT_SERVER_SETTINGS, enableProviderUpdateChecks: false }, +}).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(IdAllocator.layer), + Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge(ProviderLatestVersions.layer), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Unexpected HTTP request")), + ), + ), +); + +function nativeHost() { + let notify: Parameters[0] = () => {}; + let close!: () => void; + const closed = new Promise((resolve) => { + close = resolve; + }); + const host: sdk.MuseSdkHost = { + connection: { + mintCommandId: () => "command", + request: async (method) => + method.startsWith("session/") + ? { + session: { sessionId: "command", modelId: "muse-wonder-1.0", activeTurnId: null }, + history: { items: [] }, + } + : method === "model/list" + ? { + providerId: "meta", + models: [ + { + modelId: "muse-wonder-1.0", + displayLabel: "Muse", + providerId: "meta", + isDefault: true, + }, + ], + } + : {}, + command: async (_method, params) => ({ + session: { sessionId: params.sessionId, modelId: "muse-wonder-1.0", activeTurnId: null }, + history: { items: [] }, + }), + onNotification: (handler) => { + notify = handler; + }, + onProtocolError: () => {}, + onServerRequest: () => {}, + closed, + }, + initializeResult: { grantedCapabilities: [] }, + exited: closed.then(() => ({ code: 0, signal: null })), + close: async () => { + close(); + }, + }; + return { + host, + emit: (method: string, params: Record) => + notify({ jsonrpc: "2.0", method, params }), + }; +} + +it.layer(layerTest)("MuseDriver usage", (it) => { + it.effect( + "keeps the newest complete account report across late reports and refresh without probing for usage", + () => + Effect.gen(function* () { + const hosts: ReturnType[] = []; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + using spawn = vi.spyOn(spawner, "spawn").mockImplementation(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make("muse 1.0.3-R2198.1")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + const createHost: typeof sdk.createMuseSdkHost = async () => { + const fake = nativeHost(); + hosts.push(fake); + return fake.host; + }; + const original = (yield* Effect.promise(() => vi.importActual("./sdk.ts"))) + .createMuseSdkHostEffect; + using _hostEffect = vi + .spyOn(sdk, "createMuseSdkHostEffect") + .mockImplementation((options) => original(options, createHost)); + using _host = vi.spyOn(sdk, "createMuseSdkHost").mockImplementation(createHost); + const instanceId = ProviderInstanceId.make("muse-test"); + const instance = yield* MuseDriver.create({ + instanceId, + displayName: "Muse test", + enabled: true, + environment: [], + config: { ...MuseDriver.defaultConfig(), binaryPath: process.execPath }, + }); + yield* Stream.take(instance.snapshot.streamChanges, 1).pipe(Stream.runDrain); + expect((yield* instance.snapshot.getSnapshot).status).toBe("ready"); + expect((yield* instance.snapshot.getSnapshot).usageLimits).toBeUndefined(); + const policy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: null, + }); + const sessions = yield* Effect.forEach( + ["one", "two"], + Effect.fnUntraced(function* (id) { + const runtime = yield* instance.orchestrationAdapter.openSession({ + threadId: ThreadId.make(id), + providerSessionId: ProviderSessionId.make(id), + modelSelection: { instanceId, model: "muse-wonder-1.0", options: [] }, + runtimePolicy: policy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + yield* runtime.ensureThread({ + threadId: ThreadId.make(id), + modelSelection: { instanceId, model: "muse-wonder-1.0", options: [] }, + runtimePolicy: policy, + }); + return { fake: hosts.at(-1)!, events }; + }), + ); + const reading = (observedAtMs: number, usedPercent: number, tier: string) => ({ + window: { usedPercent, windowDurationMins: 300, resetsAtMs: 1791465557000 }, + weekly: { usedPercent: 50, resetsAtMs: 1791763200000 }, + observedAtMs, + tier, + }); + const publish = Effect.fnUntraced(function* ( + index: number, + report: ReturnType, + ) { + const session = sessions[index]!; + session.fake.emit("usage/changed", report); + // The following context event acknowledges that this host has processed the report. + session.fake.emit("session/contextUsage", { + sessionId: "command", + usedTokens: report.observedAtMs, + }); + yield* Queue.take(session.events).pipe( + Effect.repeat({ + until: (event) => + event.type === "provider_thread.updated" && + event.providerThread.contextUsage?.usedTokens === report.observedAtMs, + }), + ); + }); + yield* publish(1, reading(1791447762610, 70, "tier-2")); + const latest = yield* instance.snapshot.getSnapshot; + expect(latest.usageLimits?.unavailable).toBeUndefined(); + expect(latest.usageLimits?.windows.map((window) => window.usedPercent)).toEqual([70, 50]); + expect(latest.auth.label).toBe("tier-2"); + yield* publish(0, reading(1791447761610, 10, "tier-1")); + expect((yield* instance.snapshot.getSnapshot).usageLimits).toEqual(latest.usageLimits); + expect((yield* instance.snapshot.getSnapshot).auth.label).toBe("tier-2"); + const callsBeforeUsageRefresh = spawn.mock.calls.length; + yield* instance.snapshot.refreshUsage!; + expect(spawn.mock.calls.length).toBe(callsBeforeUsageRefresh); + const refreshed = yield* instance.snapshot.refresh; + expect(refreshed.usageLimits).toEqual(latest.usageLimits); + expect(refreshed.auth.label).toBe("tier-2"); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index c24111f07d155..197a219e54ecb 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -1,8 +1,10 @@ -import { ProviderDriverKind } from "@t3tools/contracts"; +import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { MuseSettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; @@ -20,6 +22,8 @@ import * as ProviderContinuationRequests from "@t3tools/provider-core/server/Pro import { checkMuseProviderStatus, makePendingMuseProvider } from "./status.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import type { ProviderUsageLimitsReport } from "@t3tools/provider-core/server/snapshot"; +import { makeUsageLimits } from "@t3tools/provider-core/server/usageLimits"; import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "./maintenance.ts"; import { makeMuseEnvironment } from "./sdk.ts"; import { museUsageReader } from "./usage.ts"; @@ -104,6 +108,26 @@ export const MuseDriver: ProviderDriver< accentColor, continuationGroupKey: continuationIdentity.continuationKey, }); + // Only a running CLI reports usage; status probes cannot fetch it. + const latestUsage = yield* Ref.make(undefined); + const usagePermit = yield* Semaphore.make(1); + const withLatestUsage = (provider: ServerProvider) => + Ref.get(latestUsage).pipe( + Effect.map((report): ServerProvider => + report + ? { + ...provider, + ...(report.authLabel + ? { auth: { ...provider.auth, label: report.authLabel } } + : {}), + usageLimits: makeUsageLimits({ + checkedAt: report.checkedAt, + windows: report.windows, + }), + } + : provider, + ), + ); const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const resolveInstallation = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, { @@ -148,6 +172,7 @@ export const MuseDriver: ProviderDriver< makePendingMuseProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider: checkMuseProviderStatus(effectiveConfig, processEnvironment, cwd).pipe( Effect.map(stampIdentity), + Effect.flatMap(withLatestUsage), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), @@ -185,6 +210,15 @@ export const MuseDriver: ProviderDriver< modelCatalog, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), continuationRequests, + onUsageLimits: (report) => + usagePermit.withPermits(1)( + Effect.gen(function* () { + const previous = yield* Ref.get(latestUsage); + if (previous && Date.parse(report.checkedAt) < Date.parse(previous.checkedAt)) return; + yield* Ref.set(latestUsage, report); + yield* snapshot.applyUsageLimits({ ...report, complete: true }); + }), + ), }); const textGeneration = yield* makeMuseTextGeneration(effectiveConfig, { environment: processEnvironment, @@ -197,7 +231,7 @@ export const MuseDriver: ProviderDriver< displayName, accentColor, enabled, - snapshot: { ...snapshot, resolveMaintenance }, + snapshot: { ...snapshot, resolveMaintenance, refreshUsage: Effect.void }, orchestrationAdapter, textGeneration, } satisfies ProviderInstance; diff --git a/packages/provider-muse/src/server/usageLimits.test.ts b/packages/provider-muse/src/server/usageLimits.test.ts index a633a06e82182..dddea9e6ac743 100644 --- a/packages/provider-muse/src/server/usageLimits.test.ts +++ b/packages/provider-muse/src/server/usageLimits.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; import type { MuseSubscriptionUsage } from "./protocol.ts"; -import { isMuseUsageLimitFailure, museUsageLimitResetAt } from "./usageLimits.ts"; +import { + isMuseUsageLimitFailure, + museUsageLimitResetAt, + museUsageToLimits, +} from "./usageLimits.ts"; const usage = { observedAtMs: 1791447762610, @@ -54,3 +58,31 @@ describe("Muse usage limit failures", () => { expect(museUsageLimitResetAt(undefined, afterNamedReset, { message: realRefusal })).toBeNull(); }); }); + +describe("Muse account usage", () => { + it("reports the session and weekly windows, capped at 100, with the tier as plan", () => { + expect(museUsageToLimits({ ...usage, window: { ...usage.window, usedPercent: 103 } })).toEqual({ + checkedAt: "2026-10-08T08:22:42.610Z", + authLabel: "tier-1", + windows: [ + { + id: "session", + kind: "session", + label: "Session", + usedPercent: 100, + resetsAt: "2026-10-08T13:19:17.000Z", + windowDurationMins: 300, + }, + { + id: "weekly", + kind: "weekly", + label: "Weekly", + usedPercent: 5, + resetsAt: "2026-10-12T00:00:00.000Z", + windowDurationMins: 10080, + }, + ], + }); + expect(museUsageToLimits({ ...usage, tier: " " })).not.toHaveProperty("authLabel"); + }); +}); diff --git a/packages/provider-muse/src/server/usageLimits.ts b/packages/provider-muse/src/server/usageLimits.ts index 1f97451c9338c..be981ebc009ad 100644 --- a/packages/provider-muse/src/server/usageLimits.ts +++ b/packages/provider-muse/src/server/usageLimits.ts @@ -1,7 +1,38 @@ import * as DateTime from "effect/DateTime"; +import { clampPercent } from "@t3tools/provider-core/server/usageLimits"; +import type { ProviderUsageLimitsReport } from "@t3tools/provider-core/server/snapshot"; import { LATEST_DATE_MS, type MuseSubscriptionUsage } from "./protocol.ts"; +const isoMillis = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); + +/** Muse's account-wide report as Limits windows, with its tier as the plan. */ +export function museUsageToLimits(usage: MuseSubscriptionUsage): ProviderUsageLimitsReport { + const tier = usage.tier.trim(); + return { + checkedAt: isoMillis(usage.observedAtMs), + windows: [ + { + id: "session", + kind: "session", + label: "Session", + usedPercent: clampPercent(usage.window.usedPercent), + resetsAt: isoMillis(usage.window.resetsAtMs), + windowDurationMins: usage.window.windowDurationMins, + }, + { + id: "weekly", + kind: "weekly", + label: "Weekly", + usedPercent: clampPercent(usage.weekly.usedPercent), + resetsAt: isoMillis(usage.weekly.resetsAtMs), + windowDurationMins: 7 * 24 * 60, + }, + ], + ...(tier ? { authLabel: tier } : {}), + }; +} + /** Credential proxies may omit error.quota from 429 refusals; inspect text too. */ export function isMuseUsageLimitFailure(message: string | undefined): boolean { return message !== undefined && /\b429\b/.test(message) && /quota|usage limit/i.test(message); @@ -30,5 +61,5 @@ export function museUsageLimitResetAt( (ms): ms is number => ms !== undefined && Number.isFinite(ms) && ms > nowMs && ms <= LATEST_DATE_MS, ); - return resets.length > 0 ? DateTime.formatIso(DateTime.makeUnsafe(Math.max(...resets))) : null; + return resets.length > 0 ? isoMillis(Math.max(...resets)) : null; } diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts index 5ce763e024b86..30d00390769d2 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; +import * as UrlParams from "effect/http/UrlParams"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { describe } from "vite-plus/test"; @@ -167,3 +168,83 @@ describe("OpenCode2Client message history", () => { }), ); }); + +describe("OpenCode2Client native image jobs", () => { + it.effect.each([ + { path: "/work/image.png", location: "/work" }, + { path: "/tmp/images/session/image.png", location: "/tmp/images/session" }, + ])("reads remote image $path through its native filesystem location", ({ path, location }) => + Effect.gen(function* () { + const transport = HttpClient.make((request) => + Effect.sync(() => { + assert.strictEqual(request.url, "http://remote.test/api/fs/read/image.png"); + assert.deepEqual(UrlParams.toRecord(request.urlParams), { + "location[directory]": location, + }); + return HttpClientResponse.fromWeb( + request, + new Response(Buffer.from("iVBORw0KGgo=", "base64")), + ); + }), + ); + const service = yield* OpenCode2Client.make.pipe( + Effect.provideService(HttpClient.HttpClient, transport), + ); + const api = yield* service.connect({ baseUrl: "http://remote.test", password: "secret" }); + assert.deepEqual(yield* api.readImage(path, "/work"), { + uri: "data:image/png;base64,iVBORw0KGgo=", + mime: "image/png", + }); + }), + ); + + it.effect("rejects remote previews above the shared image limit", () => + Effect.gen(function* () { + const transport = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(new Uint8Array(11 * 1024 * 1024))), + ), + ); + const service = yield* OpenCode2Client.make.pipe( + Effect.provideService(HttpClient.HttpClient, transport), + ); + const api = yield* service.connect({ baseUrl: "http://remote.test", password: "secret" }); + const result = yield* api.readImage("/work/image.png", "/work").pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }), + ); + + it.effect("reads scoped image jobs without accepting other sessions or job kinds", () => + Effect.gen(function* () { + const job = { + kind: "image", + id: "image_test", + sessionID: "ses_owner", + status: "running", + path: "/work/image", + model: "test/image", + } as const; + const transport = HttpClient.make((request) => + Effect.sync(() => { + assert.strictEqual(request.headers.authorization, utf8Basic("secret")); + return HttpClientResponse.fromWeb( + request, + Response.json({ + data: [ + job, + { ...job, sessionID: "ses_other" }, + { ...job, kind: "shell" }, + { ...job, status: "future" }, + ], + }), + ); + }), + ); + const service = yield* OpenCode2Client.make.pipe( + Effect.provideService(HttpClient.HttpClient, transport), + ); + const api = yield* service.connect({ baseUrl: "http://remote.test", password: "secret" }); + assert.deepEqual(yield* api.imageJobs("ses_owner"), [job]); + }), + ); +}); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts index 71fb1d70ce7d4..c3725156f5848 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -1,3 +1,5 @@ +import * as Path from "effect/Path"; +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; import { OpenCode, type OpenCodeClient } from "@opencode/client/effect"; import { OpenCodeEvent } from "@opencode/protocol/groups/event"; import * as Context from "effect/Context"; @@ -50,9 +52,43 @@ export type OpenCode2StreamEvent = | OpenCode2UnreadableTerminal | OpenCode2UnreadableStart; +export class OpenCode2ImageError extends Schema.TaggedError()( + "OpenCode2ImageError", + { cause: Schema.Defect() }, +) {} + +const IMAGE_MIME: Readonly> = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", +}; +export const imagePreviewMime = (extension: string) => IMAGE_MIME[extension.toLowerCase()]; + +const NativeImageJob = Schema.Struct({ + kind: Schema.Literal("image"), + id: Schema.String, + sessionID: Schema.String, + status: Schema.Literals(["running", "completed", "error", "cancelled"]), + path: Schema.String, + model: Schema.String, +}); +const decodeJobs = Schema.decodeUnknownEffect( + Schema.Struct({ data: Schema.Array(Schema.Unknown) }), +); +const decodeImageJob = Schema.decodeUnknownOption(NativeImageJob); + /** events resolves on subscription acceptance; subscribe before work because the stream is volatile. */ export interface OpenCode2Api { readonly client: OpenCodeClient; + readonly imageJobs: ( + sessionID: string, + ) => Effect.Effect, OpenCode2ImageError>; + readonly readImage: ( + path: string, + directory: string, + ) => Effect.Effect<{ uri: string; mime: string }, OpenCode2ImageError>; readonly events: Effect.Effect< Stream.Stream< OpenCode2StreamEvent, @@ -202,6 +238,7 @@ const fillCompactionRecents = HttpClient.transformResponse( /** @public */ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; + const paths = yield* Path.Path.pipe(Effect.provide(Path.layer)); return OpenCode2Client.of({ connect: Effect.fn("OpenCode2Client.connect")(function ({ baseUrl, password }) { const authenticated = HttpClient.mapRequest( @@ -212,6 +249,51 @@ export const make = Effect.gen(function* () { Effect.provideService(HttpClient.HttpClient, fillCompactionRecents(authenticated)), Effect.map((client) => ({ client, + imageJobs: (sessionID: string) => + authenticated + .get(`${baseUrl}/api/experimental/job`, { + urlParams: { sessionID, kind: "image" }, + }) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap(decodeJobs), + Effect.map((page) => + page.data.flatMap((item) => { + const job = decodeImageJob(item); + return Option.isSome(job) && job.value.sessionID === sessionID + ? [job.value] + : []; + }), + ), + Effect.mapError((cause) => new OpenCode2ImageError({ cause })), + ), + readImage: Effect.fn("OpenCode2Client.readImage")( + function* (path: string, directory: string) { + const mime = imagePreviewMime(paths.extname(path)); + if (mime === undefined) + return yield* new OpenCode2ImageError({ cause: "Unsupported image format" }); + const relative = paths.relative(directory, path); + // OpenCode confines reads to the requested location, including its own temp images. + const outside = + relative === ".." || + relative.startsWith(`..${paths.sep}`) || + paths.isAbsolute(relative); + const location = outside ? paths.dirname(path) : directory; + const file = outside ? paths.basename(path) : relative; + const response = yield* authenticated + .get(`${baseUrl}/api/fs/read/${encodeURIComponent(file)}`, { + urlParams: { "location[directory]": location }, + }) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + const bytes = yield* response.arrayBuffer; + if (bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) + return yield* new OpenCode2ImageError({ cause: "Image exceeds preview limit" }); + return { uri: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`, mime }; + }, + Effect.timeout("10 seconds"), + Effect.mapError((cause) => new OpenCode2ImageError({ cause })), + ), events: readEvents.pipe( Effect.provideService( HttpClient.HttpClient, diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index bbd70cc108cdc..19673f2597fec 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -1,3 +1,5 @@ +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as NodeURL from "node:url"; import { @@ -15,6 +17,7 @@ import { } from "@opencode/client/effect"; import { Mcp } from "@opencode/schema/mcp"; import { + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, isOrchestrationV2WorkActive, parseGoalCommand, type ChatAttachment, @@ -304,6 +307,18 @@ interface ThreadState { readonly sessionId: string; providerThread: OrchestrationV2ProviderThread; publishedBackgroundWork?: boolean; + readonly imageJobs: Map< + string, + { + readonly turn: ActiveTurn; + readonly toolId: string; + readonly tool: { readonly name: string; input: Record }; + previewed: boolean; + readonly path: string; + readonly model: string; + status: "running" | "completed" | "error" | "cancelled"; + } + >; goalRevision: number; goalFresh: boolean; goalRefreshing: boolean; @@ -529,8 +544,11 @@ const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => { const textOf = (content: ReadonlyArray<{ readonly type: string; readonly text?: string }>) => content.flatMap((part) => (part.type === "text" && part.text ? [part.text] : [])).join("\n"); -const stringField = (record: Readonly> | undefined, key: string) => { - const value = record?.[key]; +const stringField = (record: unknown, key: string) => { + const value = + typeof record === "object" && record !== null && key in record + ? Reflect.get(record, key) + : undefined; return typeof value === "string" && value.trim().length > 0 ? value : undefined; }; @@ -698,6 +716,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const host = yield* ProviderHost.ProviderHost; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* Path.Path; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const crypto = yield* Digest.Digest; const driver = OPENCODE_PROVIDER; @@ -794,6 +813,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ): ThreadState => ({ sessionId, providerThread, + imageJobs: new Map(), goalRevision: 0, goalFresh: false, goalRefreshing: false, @@ -870,6 +890,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; const owesWork = (state: ThreadState) => + [...state.imageJobs.values()].some((job) => job.status === "running") || state.wakes.length > 0 || state.reports.size > 0 || (state.subagent !== undefined && busy.has(state.sessionId)) || @@ -1161,6 +1182,31 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (projected) return; } } + if (tool.name === "experimental_image_generate" && status === "completed") { + const jobID = stringField(result?.metadata, "jobID"); + const path = stringField(result?.metadata, "path"); + const model = stringField(result?.metadata, "model"); + if (jobID !== undefined && path !== undefined && model !== undefined) { + // Tool success acknowledges launch, not the background image's outcome. + if ( + state.imageJobs.get(jobID)?.status !== undefined && + state.imageJobs.get(jobID)?.status !== "running" + ) + return; + if (!state.imageJobs.has(jobID)) + state.imageJobs.set(jobID, { + turn, + toolId: id, + tool, + previewed: false, + path, + model, + status: "running", + }); + status = "running"; + yield* syncRosterAncestors(state); + } + } const updatedAt = yield* DateTime.now; const startedAt = turn.startedAt.get(id) ?? updatedAt; const completedAt = status === "running" ? null : updatedAt; @@ -1425,14 +1471,28 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ? {} : { childThreadId: call.child.subagent.appThread.id }), })); + const imageTasks = sessionsOf(state).flatMap((session) => + [...session.imageJobs.entries()].flatMap(([taskId, job]) => + job.status === "running" + ? [ + { + taskId, + kind: "background_task" as const, + description: `Image generation (${job.model})`, + }, + ] + : [], + ), + ); + const allTasks = [...roster, ...imageTasks]; const current = state.providerThread.pendingBackgroundTasks ?? []; if ( state.publishedBackgroundWork === hasBackground(state) && - current.length === roster.length && + current.length === allTasks.length && current.every( (task, index) => - task.taskId === roster[index]?.taskId && - task.description === roster[index]?.description && + task.taskId === allTasks[index]?.taskId && + task.description === allTasks[index]?.description && (task.kind === "subagent" ? task.childThreadId : undefined) === roster[index]?.childThreadId, ) @@ -1441,7 +1501,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } state.providerThread = { ...state.providerThread, - pendingBackgroundTasks: roster, + pendingBackgroundTasks: allTasks, updatedAt: yield* DateTime.now, }; yield* emitProviderThread(state); @@ -1726,6 +1786,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* emitText(state, turn, open.block, open.kind, (text) => text, true); } for (const id of turn.tools.keys()) { + if ([...state.imageJobs.values()].some((job) => job.turn === turn && job.toolId === id)) + continue; yield* emitTool( state, turn, @@ -2266,7 +2328,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr files: event.data.content.flatMap((part) => (part.type === "file" ? [part] : [])), metadata: event.data.metadata, }); - turn.tools.delete(event.data.id); + if ( + ![...state.imageJobs.values()].some( + (job) => job.turn === turn && job.toolId === event.data.id, + ) + ) + turn.tools.delete(event.data.id); return; } case "session.tool.failed": { @@ -2389,6 +2456,85 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* offerWake(state, wake); }); + const imageReport = Effect.fn("OpenCode2Adapter.imageReport")(function* ( + state: ThreadState, + payload: { + readonly text: string; + readonly metadata?: Readonly> | undefined; + }, + ) { + const jobID = stringField(payload.metadata, "jobID"); + const job = jobID === undefined ? undefined : state.imageJobs.get(jobID); + const outcome = stringField(payload.metadata, "state"); + if ( + job === undefined || + (outcome !== "completed" && outcome !== "error" && outcome !== "cancelled") + ) + return; + // Native job inventory exposes the stem, while a completion notice names the saved file. + const saved = /Saved image: (.+) \([^\n]+\)\./.exec(payload.text)?.[1]; + const previewPath = + saved !== undefined && saved.startsWith(job.path + ".") ? saved : undefined; + if ( + job.status !== "running" && + !( + job.status === "completed" && + outcome === "completed" && + !job.previewed && + previewPath !== undefined + ) + ) + return; + const files: Array<{ uri: string; mime: string }> = []; + yield* Effect.gen(function* () { + if (outcome !== "completed" || previewPath === undefined) return; + const image = yield* ( + connection.external + ? connection.readImage(previewPath, state.directory) + : Effect.gen(function* () { + const mime = OpenCode2Client.imagePreviewMime(paths.extname(previewPath)); + const info = yield* fileSystem.stat(previewPath); + if (mime === undefined || info.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) + return yield* new OpenCode2Client.OpenCode2ImageError({ + cause: "Unsupported image preview", + }); + const bytes = yield* fileSystem.readFile(previewPath); + if (bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) + return yield* new OpenCode2Client.OpenCode2ImageError({ + cause: "Image exceeds preview limit", + }); + return { + uri: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`, + mime, + }; + }) + ).pipe(Effect.timeout(INVENTORY_TIMEOUT), Effect.option); + if (Option.isSome(image)) files.push(image.value); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + // A known outcome must still settle its tool if preview I/O is interrupted. + job.status = outcome; + job.previewed ||= files.length > 0; + job.turn.tools.set(job.toolId, job.tool); + yield* emitTool( + state, + job.turn, + job.toolId, + outcome === "completed" + ? "completed" + : outcome === "error" + ? "failed" + : "interrupted", + { output: payload.text, metadata: undefined, files }, + ); + job.turn.tools.delete(job.toolId); + yield* syncRosterAncestors(state); + }), + ), + ); + }); + const onReport = Effect.fnUntraced(function* ( state: ThreadState, inboxId: string, @@ -2397,6 +2543,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr readonly metadata?: Readonly> | undefined; }, ) { + if (stringField(payload.metadata, "source") === "experimental_image_generate") { + return yield* imageReport(state, payload); + } const childId = stringField(payload.metadata, "childID"); if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return; strays.delete(childId); @@ -2683,8 +2832,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } for (const state of threads.values()) { for (const call of runningCalls(state)) yield* settleCall(call, "failed"); + for (const [jobID, job] of state.imageJobs) { + if (job.status !== "running") continue; + yield* imageReport(state, { text: message, metadata: { jobID, state: "error" } }); + } for (const wake of state.wakes.splice(0)) wake.dropped = true; state.reports.clear(); + yield* syncRosterAncestors(state); } yield* setSessionStatus("error", message); yield* Queue.end(events); @@ -2768,7 +2922,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), metadata: part.state.metadata, }); - turn.tools.delete(part.id); + if ( + ![...state.imageJobs.values()].some( + (job) => job.turn === turn && job.toolId === part.id, + ) + ) + turn.tools.delete(part.id); } else if (part.state.status === "error") { yield* emitTool(state, turn, part.id, "failed", { output: part.state.error.message, @@ -2784,6 +2943,36 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); // Restarts retain the previous session.outcome; without an idle item, the lost execution was interrupted. + const recoverImages = Effect.fn("OpenCode2Adapter.recoverImages")(function* ( + state: ThreadState, + ) { + if (![...state.imageJobs.values()].some((job) => job.status === "running")) return; + yield* OpenCode2Client.paginate( + { sessionID: Session.ID.make(state.sessionId), type: "synthetic" as const, limit: 100 }, + client.message.list, + ).pipe( + Stream.runForEach((message) => + message.type === "synthetic" && + stringField(message.metadata, "source") === "experimental_image_generate" + ? imageReport(state, message) + : Effect.void, + ), + ); + if ( + ![...state.imageJobs.values()].some((job) => job.status === "running") || + compareSemverVersions(connection.version, "2.2.6") < 0 + ) + return; + const jobs = yield* connection.imageJobs(state.sessionId); + for (const job of jobs) { + if (job.status === "running") continue; + yield* imageReport(state, { + text: `Image generation ${job.status}. Output path: ${job.path} (${job.model}).`, + metadata: { source: "experimental_image_generate", jobID: job.id, state: job.status }, + }); + } + }); + const reconcile = Effect.fn("OpenCode2Adapter.reconcile")(function* () { // Read deepest sessions first; subagent turns end before their waiting parents. const running = [...threads] @@ -2833,6 +3022,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr : { status: "interrupted" }; yield* endExecution(state, turn, terminal); } + for (const state of threads.values()) { + yield* recoverImages(state).pipe( + Effect.timeout(INVENTORY_TIMEOUT), + Effect.ignore({ log: true }), + ); + } for (const state of background) { for (const call of runningCalls(state)) { const child = call.child?.sessionId; diff --git a/packages/shared/package.json b/packages/shared/package.json index 5d9a87ac7719d..f5f769236f906 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./pullRequestAnchor": { + "types": "./src/pullRequestAnchor.ts", + "import": "./src/pullRequestAnchor.ts" + }, "./changedFileKind": { "types": "./src/changedFileKind.ts", "import": "./src/changedFileKind.ts" diff --git a/packages/shared/src/markdownListIndentation.ts b/packages/shared/src/markdownListIndentation.ts index f41e6d2eddde2..39342890d1c27 100644 --- a/packages/shared/src/markdownListIndentation.ts +++ b/packages/shared/src/markdownListIndentation.ts @@ -1,3 +1,5 @@ +import { keepOrderedListNumbers, keepsOrderedListNumbers } from "./markdownOrderedListNumbers.ts"; + interface MarkdownPosition { readonly start?: { readonly line?: number; @@ -91,6 +93,9 @@ function parseRecoveredMarkdown(value: string, parser: MarkdownParser): Recovere function blocksFromIndentedCode(node: MarkdownAstNode, parser: MarkdownParser): RecoveredMarkdown { const value = typeof node.value === "string" ? node.value.trim() : ""; const recovered = parseRecoveredMarkdown(value, parser); + if (keepsOrderedListNumbers(parser)) { + for (const block of recovered.blocks) keepOrderedListNumbers(block, recovered.source); + } const first = recovered.blocks[0]; return { ...recovered, diff --git a/packages/shared/src/markdownOrderedListNumbers.ts b/packages/shared/src/markdownOrderedListNumbers.ts new file mode 100644 index 0000000000000..32b3b38de5633 --- /dev/null +++ b/packages/shared/src/markdownOrderedListNumbers.ts @@ -0,0 +1,62 @@ +import type { Root } from "mdast"; +import type { Transformer } from "unified"; + +interface MarkdownListNode { + readonly type: string; + readonly ordered?: boolean | null | undefined; + readonly start?: number | null | undefined; + readonly position?: + | { readonly start?: { readonly offset?: number | undefined } | undefined } + | undefined; + data?: { hProperties?: Record | undefined } | undefined; + children?: MarkdownListNode[] | undefined; +} + +const WRITTEN_NUMBER = /[ \t]*(\d{1,9})[.)]/y; + +/** + * CommonMark numbers an ordered list from its first marker only, so answers to questions 3 and 5 + * would render as 3 and 4. An item whose written number breaks the sequence keeps it. `source` is + * the text the node's offsets point into. + */ +export function keepOrderedListNumbers(node: MarkdownListNode, source: string): void { + if (!node.children) return; + if (node.type === "list" && node.ordered) { + let expected = node.start ?? 1; + for (const item of node.children) { + const offset = item.position?.start?.offset; + WRITTEN_NUMBER.lastIndex = offset ?? 0; + const digits = offset === undefined ? undefined : WRITTEN_NUMBER.exec(source)?.[1]; + const value = digits === undefined ? expected : Number(digits); + if (value !== expected) { + item.data = { + ...item.data, + hProperties: { ...item.data?.hProperties, value: String(value) }, + }; + } + expected = value + 1; + } + } + for (const child of node.children) keepOrderedListNumbers(child, source); +} + +const enabledProcessors = new WeakSet(); + +/** + * Opts a processor in. User messages use it so the sent bubble shows the numbers the composer + * showed and the agent received; agent replies and files keep CommonMark numbering. + */ +export function remarkEnableOrderedListNumbers(this: object): undefined { + enabledProcessors.add(this); +} + +export function keepsOrderedListNumbers(processor: object): boolean { + return enabledProcessors.has(processor); +} + +/** Sits before list recovery so offsets still point into the file. */ +export function remarkKeepOrderedListNumbers(this: object): Transformer { + return (tree, file) => { + if (enabledProcessors.has(this)) keepOrderedListNumbers(tree, String(file)); + }; +} diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index 0493580e93625..3d4e69be16d9e 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vite-plus/test"; -import { unified } from "unified"; +import { type PluggableList, unified } from "unified"; import rehypeRaw from "rehype-raw"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; @@ -8,6 +8,7 @@ import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, + remarkEnableOrderedListNumbers, } from "./markdownPipeline.ts"; it.each([ @@ -309,6 +310,47 @@ it("keeps an image on its own line under a label but leaves inline images inline expect(render("Hard-wrapped\nprose")).toBe("Hard-wrapped\nprose"); }); +it("keeps the number written on each ordered list item only when enabled", () => { + type Node = { tagName?: string; properties?: { value?: unknown }; children?: Node[] }; + const values = (node: Node): unknown[] => [ + ...(node.tagName === "li" ? [node.properties?.value] : []), + ...(node.children ?? []).flatMap(values), + ]; + const renderer = (plugins: PluggableList) => { + const processor = unified() + .use(remarkParse) + .use(plugins) + .use(remarkRehype, { allowDangerousHtml: true }); + return (markdown: string) => + values(processor.runSync(processor.parse(markdown), markdown) as Node); + }; + const render = renderer([ + ...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, + remarkEnableOrderedListNumbers, + ]); + + expect(render("3. first\n5. second\n6. third")).toEqual([undefined, "5", undefined]); + expect(render("1. one\n1. one again")).toEqual([undefined, "1"]); + expect(render("> 1. quoted\n> 4. skipped")).toEqual([undefined, "4"]); + expect(render("1. outer\n 1. nested\n 7. jump\n9. back")).toEqual([ + undefined, + undefined, + "7", + "9", + ]); + expect(render("1. one\n2. two\n- bullet")).toEqual([undefined, undefined, undefined]); + expect(render("- first block\n\n 3. first\n 5. second")).toEqual([ + undefined, + undefined, + "5", + ]); + expect( + renderer(CHAT_MARKDOWN_REMARK_PLUGINS)( + "3. first\n5. second\n\n- first block\n\n 3. first\n 5. second", + ), + ).toEqual([undefined, undefined, undefined, undefined, undefined]); +}); + it("moves a 's color-scheme sources onto its , which the renderer resolves", () => { const processor = unified() .use(remarkParse) diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index e4527a8a017c9..6a49d0023b0a2 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -11,6 +11,7 @@ import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { remarkGithubAlerts } from "./markdownGithubAlerts.ts"; import { remarkNormalizeListItemIndentation } from "./markdownListIndentation.ts"; +import { remarkKeepOrderedListNumbers } from "./markdownOrderedListNumbers.ts"; import { CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, remarkCodexDirectives, @@ -18,6 +19,8 @@ import { import { isWindowsDrivePathHref, normalizeMarkdownLinkDestination } from "./markdownLinks.ts"; import { THREAD_LINK_PROTOCOL } from "./threadLinks.ts"; +export { remarkEnableOrderedListNumbers } from "./markdownOrderedListNumbers.ts"; + type MarkdownImageHastNode = { type?: string; value?: string; @@ -230,6 +233,7 @@ export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [ remarkKeepAutolinkSuffixes, remarkKeepWindowsPaths, remarkGithubAlerts, + remarkKeepOrderedListNumbers, remarkNormalizeListItemIndentation, remarkCodexDirectives, remarkBreakAroundImageLines, @@ -242,6 +246,7 @@ export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [ remarkKeepAutolinkSuffixes, remarkKeepWindowsPaths, remarkGithubAlerts, + remarkKeepOrderedListNumbers, remarkNormalizeListItemIndentation, remarkCodexDirectives, remarkBreakAroundImageLines, diff --git a/packages/shared/src/mcpApp.test.ts b/packages/shared/src/mcpApp.test.ts index 6dd3c7d011493..e0d902c350566 100644 --- a/packages/shared/src/mcpApp.test.ts +++ b/packages/shared/src/mcpApp.test.ts @@ -5,6 +5,7 @@ import { injectMcpAppCsp, injectMcpAppThemeBootstrap, injectMcpAppCookieBootstrap, + MCP_APP_COLOR_SCHEME_PARAM, mcpAppContentSecurityPolicy, mcpAppToolCallableByApp, mcpAppToolResourceUri, @@ -101,6 +102,28 @@ describe("injectMcpAppThemeBootstrap", () => { send(initialized); expect(style.colorScheme).toBe("dark"); }); + + it("applies only a valid scheme from the frame URL before any message arrives", () => { + const html = injectMcpAppThemeBootstrap("

    app

    "); + const script = html.slice(html.indexOf("")); + const initialScheme = (search: string) => { + const head: Array<{ name?: string; content?: string }> = []; + NodeVM.runInNewContext(script, { + URLSearchParams, + location: { search }, + window: { parent: {}, addEventListener: () => {} }, + document: { + createElement: () => ({}), + head: { append: (element: (typeof head)[number]) => head.push(element) }, + }, + }); + return head.find((element) => element.name === "color-scheme")?.content ?? "normal"; + }; + expect(initialScheme(`?a=1&${MCP_APP_COLOR_SCHEME_PARAM}=dark`)).toBe("dark"); + expect(initialScheme(`?${MCP_APP_COLOR_SCHEME_PARAM}=light`)).toBe("light"); + expect(initialScheme(`?${MCP_APP_COLOR_SCHEME_PARAM}=normal`)).toBe("normal"); + expect(initialScheme("")).toBe("normal"); + }); }); describe("readMcpAppCsp", () => { diff --git a/packages/shared/src/mcpApp.ts b/packages/shared/src/mcpApp.ts index 35a50306423e5..40b3975ebd645 100644 --- a/packages/shared/src/mcpApp.ts +++ b/packages/shared/src/mcpApp.ts @@ -5,6 +5,8 @@ export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; export const MCP_APP_EXTENSION_ID = "io.modelcontextprotocol/ui"; export const MCP_APP_RESOURCE_SCHEME = "ui://"; export const MCP_APP_OUTPUT_KEY = "t3McpApp"; +/** Frame URL query parameter carrying the host's color scheme before `ui/initialize`. */ +export const MCP_APP_COLOR_SCHEME_PARAM = "t3-color-scheme"; const MCP_APP_MIN_HEIGHT = 80; export const MCP_APP_DEFAULT_HEIGHT = 320; @@ -201,6 +203,16 @@ export function injectMcpAppCsp(html: string, csp: McpAppCsp | undefined): strin export function injectMcpAppThemeBootstrap(html: string): string { const script = `