diff --git a/.github/workflows/cursor-hygiene-webhook.yml b/.github/workflows/cursor-hygiene-webhook.yml index 8f8dde38a5fe6..19c82229e98fc 100644 --- a/.github/workflows/cursor-hygiene-webhook.yml +++ b/.github/workflows/cursor-hygiene-webhook.yml @@ -3,7 +3,9 @@ name: Forward to Cursor hygiene on: push: branches: [main] - pull_request: + # Use trusted workflow code and repository secrets for fork PRs. + # Keep this job payload-only: never check out or run PR code. + pull_request_target: types: [opened, reopened, ready_for_review] issues: types: [opened, closed, reopened] @@ -34,7 +36,7 @@ jobs: --output /dev/null --write-out '%{http_code}' \ -H "Authorization: $AUTH" \ -H "Content-Type: application/json" \ - -H "X-GitHub-Event: ${{ github.event_name }}" \ + -H "X-GitHub-Event: ${{ github.event_name == 'pull_request_target' && 'pull_request' || github.event_name }}" \ -H "X-GitHub-Delivery: ${{ github.run_id }}-${{ github.run_attempt }}" \ --data-binary @"${{ github.event_path }}") || curl_status=$? if [ "$curl_status" -eq 0 ]; then diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 58fff8297898b..e415a15489dcb 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account. import * as NodeHttp from "node:http"; -import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodePath from "node:path"; +import * as PlatformNodePath from "@effect/platform-node/NodePath"; import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -59,11 +60,15 @@ const layerDesktopClerk = ( copyText: () => Effect.void, }, listeners = new Map void>(), + isPackaged = false, ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, appDataDirectory: "/tmp/app-data", + isPackaged, + dirname: platform === "win32" ? String.raw`C:\T3 Code\dist-electron` : "/tmp/dist-electron", + path: platform === "win32" ? NodePath.win32 : NodePath.posix, platform, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -95,7 +100,7 @@ const layerDesktopClerk = ( return DesktopClerk.layer.pipe( Layer.provide( Layer.mergeAll( - NodePath.layerPosix, + PlatformNodePath.layerPosix, Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), Layer.succeed(ElectronShell.ElectronShell, shell), @@ -112,6 +117,67 @@ describe("DesktopClerk", () => { storageMock.mockReset(); }); + it.effect.each([ + { platform: "win32", isPackaged: false, isDevelopment: false, registers: true }, + { platform: "win32", isPackaged: false, isDevelopment: true, registers: false }, + { platform: "win32", isPackaged: true, isDevelopment: false, registers: false }, + { platform: "darwin", isPackaged: false, isDevelopment: false, registers: false }, + { platform: "linux", isPackaged: false, isDevelopment: false, registers: false }, + ] as const)( + "registers an entry point only for unpackaged non-development Windows ($platform, packaged=$isPackaged, development=$isDevelopment)", + ({ platform, isPackaged, isDevelopment, registers }) => { + const events: string[] = []; + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockImplementation(() => { + events.push("bridge"); + return { cleanup: vi.fn() }; + }); + const setAsDefaultProtocolClient = vi.fn(() => + Effect.sync(() => { + events.push("register"); + return true; + }), + ); + const electronApp = { + on: () => Effect.void, + setAsDefaultProtocolClient, + } as unknown as ElectronApp.ElectronApp["Service"]; + + return Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + + assert.deepEqual( + setAsDefaultProtocolClient.mock.calls, + registers + ? [["t3code", process.execPath, [String.raw`C:\T3 Code\dist-electron\main.cjs`]]] + : [], + ); + assert.deepEqual(events, registers ? ["bridge", "register"] : ["bridge"]); + }).pipe( + Effect.scoped, + Effect.provide( + layerDesktopClerk( + isDevelopment, + [], + platform, + undefined, + undefined, + undefined, + isPackaged, + ), + ), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService( + ElectronWindow.ElectronWindow, + {} as ElectronWindow.ElectronWindow["Service"], + ), + Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), + Effect.provideService(HostProcess.Arguments, ["electron"]), + ); + }, + ); + it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); const events: string[] = []; diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 505b48194cf7c..f4de0b4ebaf59 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -151,6 +151,20 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); + // Clerk's bare scheme registration omits the app entry on unpackaged Windows. + // Development callbacks also need dev server environment this command cannot restore. + if ( + !environment.isPackaged && + !environment.isDevelopment && + environment.platform === "win32" + ) { + yield* electronApp.setAsDefaultProtocolClient( + ElectronProtocol.getDesktopScheme(environment.isDevelopment), + process.execPath, + [environment.path.join(environment.dirname, "main.cjs")], + ); + } + const startProviderAuthHandoff = (value: string | undefined) => { if (!value) return false; const request = readCodexAuthHandoff(value, environment.isDevelopment); diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 644597eaf451d..34c3c9080eac0 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -120,6 +120,43 @@ describe("WSL IPC", () => { ), ); + it.effect("does not enumerate distros until the WSL backend is enabled", () => { + const distros = [ + { name: "Ubuntu", isDefault: true, version: 2 as const }, + { name: "Debian", isDefault: false, version: 2 as const }, + ]; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslDistro: "Debian", + }), + DesktopWslEnvironment.layerTest({ isAvailable: true, distros }), + layerWslBackend(), + layerLifecycle([]), + layerUnusedLifecycleRuntime, + ); + + return Effect.gen(function* () { + const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const state = yield* getWslState.handler(undefined).pipe( + Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, { + ...wsl, + listDistros: Effect.die("must not enumerate distros"), + }), + Effect.flatMap(decodeWslState), + ); + assert.equal(state.enabled, false); + assert.equal(state.available, true); + assert.equal(state.distro, "Debian"); + assert.deepEqual(state.distros, []); + + const enabledState = yield* invokeSetWslBackendEnabled(true); + assert.equal(enabledState.enabled, true); + assert.equal(enabledState.distro, "Debian"); + assert.deepEqual(enabledState.distros, distros); + }).pipe(Effect.provide(layer)); + }); + it.effect("stages dual-backend preferences before enabling without relaunching", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 3c10f04512b4c..6a36e87bbfcbf 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -22,9 +22,9 @@ const readWslState: Effect.Effect< const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); - // Only enumerate distros when WSL is actually available — listDistros on a - // non-WSL host would spawn wsl.exe and hit the timeout for nothing. - const distros = available ? yield* wslEnvironment.listDistros : []; + // Windows ships a wsl.exe stub that can prompt to install WSL when invoked. + // Keep disabled state reads from launching it to enumerate distros. + const distros = available && settings.wslBackendEnabled ? yield* wslEnvironment.listDistros : []; const preflightError = yield* wslBackend.lastPreflightError; return { enabled: settings.wslBackendEnabled, diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index eace39003e39d..7cf73ef633b05 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -151,56 +151,57 @@ describe("readChromiumCookieDatabase", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-chromium-cookies-", - }); - const filename = `${directory}/Cookies`; - const key = Buffer.from("0123456789abcdef"); + it.effect.each([14, 24])( + "imports mixed TEXT and BLOB cookie values, including empty values, in schema %i", + (schemaVersion) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); + const host = "secure.example"; + const value = "stored encrypted"; + const payload = + schemaVersion >= 24 + ? Buffer.concat([ + NodeCrypto.createHash("sha256").update(host).digest(), + Buffer.from(value), + ]) + : Buffer.from(value); + const encrypted = encryptV10(payload, key); - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`create table meta (key text primary key, value text not null)`; - yield* sql`insert into meta values ('version', 23)`; - yield* sql` - create table cookies ( - host_key text not null, - name text not null, - value text not null, - encrypted_value blob not null, - path text not null, - expires_utc integer not null, - is_secure integer not null, - is_httponly integer not null, - samesite integer not null, - top_frame_site_key text not null default '' - ) - `; - yield* sql` - insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values - ('plain.example', 'plain', 'stored plaintext', ${new Uint8Array()}, '/', 0, 0, 0, -1) - `; - yield* sql` - insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values - ('secure.example', 'encrypted', '', ${encryptV10("stored encrypted", key)}, '/', 0, 1, 1, 2) - `; - yield* sql` - insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values - ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0) - `; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', ${schemaVersion})`; + yield* sql`create table cookies ( + host_key text not null, name text not null, value text not null, + encrypted_value blob not null, path text not null, expires_utc integer not null, + is_secure integer not null, is_httponly integer not null, samesite integer not null + )`; + if (schemaVersion >= 15) { + yield* sql`alter table cookies add column top_frame_site_key text not null default ''`; + } + yield* sql`insert into cookies + (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('plain.example', 'plain', 'stored plaintext', '', '/', 0, 0, 0, -1), + (${host}, 'text-encrypted', '', cast(${encrypted} as text), '/', 0, 1, 1, 2), + (${host}, 'blob-encrypted', '', ${encrypted}, '/', 0, 1, 1, 2), + ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); - expect(result.undecryptable).toBe(0); - expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ - { name: "plain", value: "stored plaintext" }, - { name: "encrypted", value: "stored encrypted" }, - { name: "empty", value: "" }, - ]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + expect(result.undecryptable).toBe(0); + expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "plain", value: "stored plaintext" }, + { name: "text-encrypted", value }, + { name: "blob-encrypted", value }, + { name: "empty", value: "" }, + ]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("enforces domain binding only for schema 24 and newer", () => diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index d5f7592b26068..a3720f962b650 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -200,12 +200,14 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu Effect.flatMap(decodeSchemaVersion), Effect.map(([row]) => row.value), ); + // SQLite's BLOB affinity still permits TEXT. Cast before Node decodes + // those bytes as UTF-8, which can corrupt an encrypted payload. const raw = schemaVersion >= 15 - ? yield* sql`select host_key, name, value, encrypted_value, path, + ? yield* sql`select host_key, name, value, cast(encrypted_value as blob) as encrypted_value, path, expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, samesite, top_frame_site_key from cookies` - : yield* sql`select host_key, name, value, encrypted_value, path, + : yield* sql`select host_key, name, value, cast(encrypted_value as blob) as encrypted_value, path, expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, samesite, '' as top_frame_site_key from cookies`; return { rows: yield* decodeCookieRows(raw), schemaVersion }; diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index a98aa96079e67..7313b9b859e39 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -283,7 +283,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ - mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, + mainWindowBounds: { x: 10, y: 20, width: 599, height: 620 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", }); @@ -342,17 +342,24 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 600, height: 620 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { - mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowBounds: { x: -1200, y: 40, width: 600, height: 620 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); + + const restored = yield* Effect.gen(function* () { + const freshSettings = yield* DesktopAppSettings.DesktopAppSettings; + return yield* freshSettings.load; + }).pipe(Effect.provide(DesktopAppSettings.layer)); + assert.deepEqual(restored.mainWindowBounds, persisted.mainWindowBounds); + assert.isTrue(restored.mainWindowMaximized); }), ), ); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 1c131681295fa..a26892d6984a1 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -48,8 +48,8 @@ export interface DesktopSettingsChange { } const DEFAULT_TAILSCALE_SERVE_PORT = 443; -const MIN_MAIN_WINDOW_SIZE = { - width: 840, +export const MIN_MAIN_WINDOW_SIZE = { + width: 600, height: 620, } as const; export const DesktopWindowBoundsSchema = Schema.Struct({ diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 27f4298a49f82..9e50257c9a0c5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Clock from "effect/Clock"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as PlatformError from "effect/PlatformError"; @@ -9,6 +12,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; import * as NodeOS from "node:os"; import { vi } from "vite-plus/test"; @@ -74,7 +78,10 @@ function runShellEnvironment(input: { readonly userShell?: string; readonly handler: ( command: ChildProcess.Command, - ) => string | { output: string; exitCode: number }; + ) => + | string + | { output: string; exitCode: number } + | Effect.Effect; readonly failure?: PlatformError.PlatformError; }) { const layerEnvironment = Layer.succeed( @@ -88,6 +95,7 @@ function runShellEnvironment(input: { ChildProcessSpawner.make((command) => { if (input.failure !== undefined) return Effect.fail(input.failure); const result = input.handler(command); + if (Effect.isEffect(result)) return result; return Effect.succeed( typeof result === "string" ? makeProcess(result) @@ -489,6 +497,85 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("waits at most 5 seconds in total when the PowerShell profile hangs", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32" }; + const profileShells: string[] = []; + const started = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const baseClock = yield* Clock.Clock; + let wallTime = 0; + const clockWithBackwardWallJump: Clock.Clock = { + ...baseClock, + currentTimeMillisUnsafe: () => wallTime, + currentTimeMillis: Effect.sync(() => wallTime), + }; + + const install = yield* Effect.forkChild( + runShellEnvironment({ + env, + platform: "win32", + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + if (command.args.includes("-NoProfile")) { + return envOutput({ PATH: "C:\\NoProfile\\Bin;C:\\Windows\\System32" }); + } + profileShells.push(command.command); + return Deferred.succeed(started, undefined).pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + ...makeProcess(""), + stdout: Stream.never, + all: Stream.never, + exitCode: Effect.never, + isRunning: Effect.succeed(true), + }), + ), + ); + }, + }).pipe( + Effect.tap(() => Deferred.succeed(completed, undefined)), + Effect.provide(Logger.layer([])), + Effect.provideService(Clock.Clock, clockWithBackwardWallJump), + ), + ); + yield* Deferred.await(started); + wallTime = -60_000; + yield* TestClock.adjust("5 seconds"); + assert.isTrue(yield* Deferred.isDone(completed), "finishes within the shared budget"); + assert.isFalse(yield* Fiber.join(install)); + assert.deepEqual(profileShells, ["pwsh.exe"]); + assert.equal(env.PATH, "C:\\NoProfile\\Bin;C:\\Windows\\System32"); + }), + ); + + it.effect("falls back to Windows PowerShell when pwsh.exe is not installed", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32" }; + const missing = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: "pwsh.exe", + }); + + const prepared = yield* runShellEnvironment({ + env, + platform: "win32", + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + if (command.command === "pwsh.exe") return Effect.fail(missing); + return command.args.includes("-NoProfile") + ? envOutput({ PATH: "C:\\Windows\\System32" }) + : envOutput({ PATH: "C:\\Profile\\Node;C:\\Windows\\System32" }); + }, + }).pipe(Effect.provide(Logger.layer([]))); + + assert.isTrue(prepared); + assert.equal(env.PATH, "C:\\Profile\\Node;C:\\Windows\\System32"); + }), + ); + it.effect.each([false, true])( "leaves Windows preparation incomplete when loadProfile=%s fails", (loadProfile) => diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index ad21a8d617635..b033f1cead20f 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,4 +1,5 @@ import { listLoginShellCandidates, setWindowsPath } from "@t3tools/shared/shell"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -377,12 +378,16 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn captureWindowsEnvironmentCommand(names), ]; + const deadline = + (yield* Clock.monotonicTimeNanos) + Duration.toNanosUnsafe(LOGIN_SHELL_TIMEOUT); for (const command of WINDOWS_SHELL_CANDIDATES) { + const remainingNanos = deadline - (yield* Clock.monotonicTimeNanos); + if (remainingNanos <= 0n) break; const output = yield* runCommandOutput({ probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile", command, args, - timeout: LOGIN_SHELL_TIMEOUT, + timeout: Duration.nanos(remainingNanos), }); const environment = extractEnvironment(output, names); if (Object.keys(environment).length > 0) { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 56206a2279631..827a48997fb86 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -660,6 +660,8 @@ describe("DesktopWindow", () => { assert.equal(yield* Ref.get(createCount), 1); assert.equal(createdWindowOptions[0]?.width, 1100); assert.equal(createdWindowOptions[0]?.height, 780); + assert.equal(createdWindowOptions[0]?.minWidth, 600); + assert.equal(createdWindowOptions[0]?.minHeight, 620); assert.isUndefined(createdWindowOptions[0]?.x); assert.isUndefined(createdWindowOptions[0]?.y); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); @@ -918,7 +920,7 @@ describe("DesktopWindow", () => { createdWindowOptions, desktopSettings: { ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + mainWindowBounds: { x: 120, y: 80, width: 768, height: 780 }, }, }); @@ -926,8 +928,8 @@ describe("DesktopWindow", () => { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - assert.equal(createdWindowOptions[0]?.width, 1320); - assert.equal(createdWindowOptions[0]?.height, 880); + assert.equal(createdWindowOptions[0]?.width, 768); + assert.equal(createdWindowOptions[0]?.height, 780); assert.equal(createdWindowOptions[0]?.x, 120); assert.equal(createdWindowOptions[0]?.y, 80); }).pipe(Effect.provide(layer)); @@ -1152,14 +1154,14 @@ describe("DesktopWindow", () => { move(); yield* TestClock.adjust(250); - fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 }); + fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 664, height: 780 }); resize(); yield* TestClock.adjust(499); assert.deepEqual(mainWindowBoundsUpdates, []); yield* TestClock.adjust(1); yield* Effect.promise(() => Promise.resolve()); - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 664, height: 780 }]); }).pipe(Effect.provide(layer)); }), ); @@ -1241,7 +1243,7 @@ describe("DesktopWindow", () => { it.effect("does not persist bounds that fail the domain schema", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); - fakeWindow.getBounds.mockReturnValue({ x: 100.4, y: 80.2, width: 839.4, height: 619.4 }); + fakeWindow.getBounds.mockReturnValue({ x: 100, y: 80, width: 599, height: 620 }); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 5d14fdfdc53f2..f99eeffd62c2f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -383,8 +383,8 @@ export const make = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return Option.none(); const window = yield* electronWindow.create({ ...initialBounds, - minWidth: 840, - minHeight: 620, + minWidth: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.width, + minHeight: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.height, show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index ca2426f8c023f..1d57632eee5fc 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -754,16 +754,22 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { return shouldIntercept } - // coerceToText opens content: URIs synchronously. Leave URI-backed - // clipboard items to Android's normal paste path so the UI thread never - // reads an arbitrary provider just to measure a text paste. - private fun ClipData.plainText(): String? = - takeIf { itemCount > 0 } - ?.getItemAt(0) - ?.takeIf { it.uri == null } - ?.coerceToText(context) - ?.toString() - ?.takeIf(String::isNotEmpty) + // Match native paste's newlines, including empty items. + // URI-backed clips stay native: coerceToText can read a provider synchronously. + private fun ClipData.plainText(): String? { + var joined: StringBuilder? = null + for (index in 0 until itemCount) { + val item = getItemAt(index) + if (item.uri != null) return null + val itemText = item.coerceToText(context) ?: continue + if (joined == null) { + joined = StringBuilder(itemText) + } else { + joined.append('\n').append(itemText) + } + } + return joined?.toString()?.takeIf(String::isNotEmpty) + } override fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean { if (keyCode == KeyEvent.KEYCODE_V && event.isCtrlPressed && event.isShiftPressed) { diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt index 9838c6314680b..ca15fb2b1ba19 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt @@ -1,8 +1,10 @@ package expo.modules.t3composereditor import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context +import android.net.Uri import android.view.KeyEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -91,6 +93,53 @@ class ComposerPasteTest { assertEquals("old", editor.text.toString()) } + @Test + fun regularPasteMeasuresAndInterceptsEveryTextItemJoinedWithNewlines() { + // Only the complete UTF-8 payload, including both newlines, reaches 32 KiB. + val first = "é".repeat(8191) + val last = "x".repeat(16 * 1024) + val clip = ClipData.newPlainText("test", first).apply { + addItem(ClipData.Item("")) + addItem(ClipData.Item(last)) + } + clipboard.setPrimaryClip(clip) + editor.setText("before old after") + editor.setSelection(10, 7) + val intercepted = mutableListOf>() + editor.pasteTextListener = { text, start, end -> intercepted.add(Triple(text, start, end)) } + + assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) + assertEquals(listOf(Triple("$first\n\n$last", 7, 10)), intercepted) + assertEquals("before old after", editor.text.toString()) + assertEquals(10, editor.selectionStart) + assertEquals(7, editor.selectionEnd) + } + + @Test + fun regularPasteKeepsClipsWithAnyUriItemNative() { + editor.textPasteThresholdBytes = 1 + editor.pasteTextListener = { _, _, _ -> error("URI-backed clips must stay native") } + for (uriIndex in 0..2) { + val items = (0..2).map { index -> + if (index == uriIndex) { + ClipData.Item("ITEM_$index", null, Uri.parse("content://test/item/$index")) + } else { + ClipData.Item("ITEM_$index") + } + } + val clip = ClipData("test", arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN), items[0]).apply { + addItem(items[1]) + addItem(items[2]) + } + clipboard.setPrimaryClip(clip) + editor.setText("before old after") + editor.setSelection(10, 7) + + assertTrue(editor.onTextContextMenuItem(android.R.id.paste)) + assertEquals("before ITEM_0\nITEM_1\nITEM_2 after", editor.text.toString()) + } + } + @Test fun shortcutPastesStructuredClipboardAsText() { clipboard.setPrimaryClip( diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index a49fa6ef391b7..3d06dfd9f344a 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -34,6 +34,7 @@ import { export type ComposerEditorProps = NativeComposerEditorProps & { readonly draftKey?: string | null; + readonly hidden?: boolean; readonly environmentId?: EnvironmentId; readonly onOpenMention?: (path: string) => void; readonly onOpenAttachment?: (attachment: ComposerDocumentAttachment) => void; @@ -43,6 +44,7 @@ export type ComposerEditorProps = NativeComposerEditorProps & { export function ComposerEditor({ draftKey, + hidden, environmentId, onOpenMention, onOpenAttachment, @@ -155,7 +157,9 @@ export function ComposerEditor({ ); return ( <> + {/* Reset revision history with the recreated iOS view without aborting context imports. */} diff --git a/apps/mobile/src/features/browser/BrowserTabMenu.tsx b/apps/mobile/src/features/browser/BrowserTabMenu.tsx index 0ade61a7a1b0e..916fde2470ec3 100644 --- a/apps/mobile/src/features/browser/BrowserTabMenu.tsx +++ b/apps/mobile/src/features/browser/BrowserTabMenu.tsx @@ -1,4 +1,5 @@ import * as Dialog from "../../components/ui/Dialog"; +import { useAtomValue } from "@effect/atom-react"; import type { MenuAction } from "@react-native-menu/menu"; import { DEFAULT_PREVIEW_ZOOM_FACTOR, @@ -40,16 +41,18 @@ const VIEWPORTS = [ export function BrowserTabMenu({ environmentId, tab, - disabled, + streaming, onPaste, }: { readonly environmentId: EnvironmentId; readonly tab: PreviewSessionSnapshot; - readonly disabled: boolean; + readonly streaming: boolean; readonly onPaste?: () => void; }) { const adjust = useAtomCommand(previewEnvironment.adjust, "browser tab change"); const resize = useAtomCommand(previewEnvironment.resize, "browser viewport change"); + const close = useAtomCommand(previewEnvironment.close, "browser tab close"); + const canClose = useAtomValue(previewEnvironment.close.permissionAtom(environmentId)); const target = { threadId: tab.threadId as PreviewAdjustInput["threadId"], tabId: tab.tabId }; const colorScheme = tab.colorScheme ?? "system"; const zoomFactor = tab.zoomFactor ?? DEFAULT_PREVIEW_ZOOM_FACTOR; @@ -65,6 +68,12 @@ export function BrowserTabMenu({ ? `preset:${tab.viewport.presetId}` : "custom"; + const closeAction: MenuAction = { + id: "close", + title: "Close tab", + image: "xmark", + attributes: { destructive: true, disabled: !canClose }, + }; const actions: MenuAction[] = [ ...(onPaste ? [{ id: "paste", title: "Paste", image: "doc.on.clipboard" }] : []), { id: "hard-reload", title: "Hard reload", image: "arrow.clockwise.circle" }, @@ -107,6 +116,7 @@ export function BrowserTabMenu({ { id: "clear:cache", title: "Clear cache", attributes: { destructive: true } }, ], }, + closeAction, ]; const run = async (change: Omit) => { @@ -115,6 +125,12 @@ export function BrowserTabMenu({ }; const onAction = (id: string) => { + if (id === "close") { + void close({ environmentId, input: target }).then((result) => { + if (result._tag === "Failure") Dialog.alert("Could not close this browser tab"); + }); + return; + } if (id === "paste") return onPaste?.(); if (id === "hard-reload") return void run({ hardReload: true }); if (id === "zoom:in") return void run({ zoomFactor: zoomTo(1) }); @@ -138,19 +154,18 @@ export function BrowserTabMenu({ } }; - const pill = ( - - ); - if (disabled) return pill; return ( onAction(nativeEvent.event)} > - {pill} + ); } diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 28fab112c8630..b6203916dea1c 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -13,7 +13,10 @@ import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search- import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; import { useDeviceType } from "../../native/native-layout-metrics"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; -import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items"; +import { + createSidebarFilterHeaderItem, + createSidebarHeaderItems, +} from "../threads/sidebar-native-header-items"; import type { HomeHeaderProps } from "./HomeHeader.types"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; @@ -58,7 +61,19 @@ export function HomeHeader(props: HomeHeaderProps) { const filterMenu = buildHomeListFilterMenu(props); if (props.usesWorkspaceToolbar) { - return null; + return deviceType === "duo-unfolded" ? ( + + ) : null; } return ( diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index d6819517deb6b..2b23ad865f042 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -14,6 +14,7 @@ import { } from "@react-navigation/native"; import { NativeStackView, + type NativeStackHeaderItemMenu, type NativeStackNavigationEventMap, type NativeStackNavigationOptions, type NativeStackNavigatorProps, @@ -62,6 +63,7 @@ function ColumnScreen(props: { readonly primary?: boolean; readonly selectedThreadKey?: string | null; readonly selectedPullRequestKey?: string | null; + readonly sidebarFilter?: NativeStackHeaderItemMenu; readonly onDismiss: (routeKey: string) => void; readonly navigation: ViewProps["navigation"]; }) { @@ -102,6 +104,7 @@ function ColumnScreen(props: { options={descriptor.options} canGoBack={!props.primary} primary={props.primary} + sidebarFilter={props.sidebarFilter} /> ) : null; })} diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index dcece75411ead..dcc1f66cd2b02 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -44,13 +44,13 @@ import { useRoute, type StaticScreenProps, } from "@react-navigation/native"; -import { useAtomRefresh } from "@effect/atom-react"; +import { RegistryContext, useAtomRefresh } from "@effect/atom-react"; import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts"; import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading"; import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink"; import { pullRequestStatus } from "./pullRequestStatus"; -import { useEffect, useRef, useState } from "react"; +import { use, useEffect, useRef, useState } from "react"; import { Linking, Platform, @@ -354,13 +354,22 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { - pullRefresh.onRefresh(); - if (guidesEnabled) refreshGuide(); - stack.refresh(); - refreshActivity(); - filesContent.refresh(); - }; + const registry = use(RegistryContext); + const refresh = () => + pullRefresh.onRefresh(async (signal) => { + const target = requestTarget(params); + await composerPullRequests.invalidate + .run(registry, { + environmentId: target.environmentId, + input: { reference: target.input }, + }) + .catch(() => undefined); + if (signal.aborted) return; + if (guidesEnabled) refreshGuide(); + stack.refresh(); + refreshActivity(); + filesContent.refresh(); + }); const jumpToFiles = () => { scrollViews.current.overview?.scrollToOffset({ offset: bodyY.current + filesY.current - insets.top, diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index 6f55fef7c2a7c..e89338c55e735 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -252,6 +252,91 @@ describe("getCachedNativeReviewDiffData", () => { }); }); +describe("buildNativeReviewDiffData row bound", () => { + function singleFilePatch(path: string, lineCount: number) { + return [ + `diff --git a/${path} b/${path}`, + `--- a/${path}`, + `+++ b/${path}`, + `@@ -0,0 +1,${lineCount} @@`, + ...Array.from({ length: lineCount }, (_, index) => `+const value${index} = ${index};`), + ].join("\n"); + } + + it("caps each huge file excerpt while preserving full counts and ordinary files", () => { + const data = buildNativeReviewDiffData( + buildReviewParsedDiff( + [singleFilePatch("huge.ts", 200_000), singleFilePatch("ordinary.ts", 1_000)].join("\n"), + "native-review-row-bound", + ), + ); + const hugeHeader = data.rows.find((row) => row.kind === "file" && row.filePath === "huge.ts"); + const ordinaryHeader = data.rows.find( + (row) => row.kind === "file" && row.filePath === "ordinary.ts", + ); + expect(hugeHeader).toMatchObject({ additions: 200_000, deletions: 0 }); + expect(ordinaryHeader).toMatchObject({ additions: 1_000, deletions: 0 }); + expect(data).toMatchObject({ additions: 201_000, deletions: 0 }); + const hugeRows = data.rows.filter((row) => row.fileId === hugeHeader?.fileId); + expect(hugeRows.filter((row) => row.kind === "line" || row.kind === "hunk")).toHaveLength( + 2_000, + ); + expect(hugeRows.at(-2)).toMatchObject({ kind: "line", newLineNumber: 1_999 }); + expect(hugeRows.at(-1)).toMatchObject({ + kind: "notice", + text: "This diff is too large to show in full.", + }); + const ordinaryRows = data.rows.filter((row) => row.fileId === ordinaryHeader?.fileId); + expect(ordinaryRows.filter((row) => row.kind === "line")).toHaveLength(1_000); + expect(ordinaryRows.at(-1)).toMatchObject({ + kind: "line", + newLineNumber: 1_000, + content: "const value999 = 999;", + }); + expect(ordinaryRows.some((row) => row.kind === "notice")).toBe(false); + }); + + it("omits comments beyond the excerpt and retains overlapping ranges at its boundary", () => { + const diff = buildReviewParsedDiff(singleFilePatch("example.ts", 3_000), "excerpt-comments"); + const visible = { ...makeComment("Visible"), id: "visible", startIndex: 10, endIndex: 10 }; + const overlapping = { + ...makeComment("Overlapping"), + id: "overlapping", + startIndex: 1_998, + endIndex: 2_001, + rangeLabel: "+1999 to +2002", + }; + const omitted = { + ...makeComment("Outside excerpt"), + id: "omitted", + startIndex: 1_999, + endIndex: 1_999, + rangeLabel: "+2000", + }; + const data = getCachedNativeReviewDiffData({ + parsedDiff: diff, + comments: [visible, overlapping, omitted], + }); + + const visibleIndex = data.rows.findIndex((row) => row.id === visible.id); + expect(data.rows[visibleIndex - 1]).toMatchObject({ kind: "line", newLineNumber: 11 }); + const overlappingIndex = data.rows.findIndex((row) => row.id === overlapping.id); + expect(data.rows[overlappingIndex - 1]).toMatchObject({ kind: "line", newLineNumber: 1_999 }); + expect(data.rows[overlappingIndex]).toMatchObject({ + commentRangeLabel: overlapping.rangeLabel, + commentText: overlapping.text, + }); + expect(data.rows.filter((row) => row.kind === "comment").map((row) => row.id)).toEqual([ + visible.id, + overlapping.id, + ]); + expect(data.rows[overlappingIndex + 1]).toMatchObject({ kind: "notice" }); + expect(getCachedNativeReviewDiffData({ parsedDiff: diff }).rows).toEqual( + data.rows.filter((row) => row.kind !== "comment"), + ); + }); +}); + describe("createNativeReviewDiffTheme", () => { it("serializes every native color as cross-platform opaque hex", () => { for (const themeId of MOBILE_THEME_IDS) { diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index f09206703298c..7e10d2024cedc 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -19,6 +19,9 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; +// Bound the rows serialized to the native heap before mapping and word matching. +const NATIVE_REVIEW_MAX_FILE_ROWS = 2_000; +const TRUNCATED_FILE_NOTICE = "This diff is too large to show in full."; const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})?$/i; const NATIVE_RGBA_COLOR = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; @@ -131,6 +134,7 @@ interface PreparedNativeReviewFileRows { readonly fileId: string; readonly filePath: string; readonly lineCount: number; + readonly truncated: boolean; readonly rows: ReadonlyArray; readonly commentTargetsByRowId: ReadonlyMap; readonly rowIdByCommentLineId: ReadonlyMap; @@ -435,9 +439,13 @@ function prepareFileRows(file: ReviewRenderableFile): PreparedNativeReviewFileRo }, ]; - const lineRows = file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line"); + const sourceRows = + file.rows.length > NATIVE_REVIEW_MAX_FILE_ROWS + ? file.rows.slice(0, NATIVE_REVIEW_MAX_FILE_ROWS) + : file.rows; + const lineRows = sourceRows.filter((row): row is ReviewRenderableLineRow => row.kind === "line"); let lineIndex = 0; - file.rows.forEach((row, rowIndex) => { + sourceRows.forEach((row, rowIndex) => { if (row.kind === "hunk") { rows.push({ kind: "hunk", @@ -459,11 +467,15 @@ function prepareFileRows(file: ReviewRenderableFile): PreparedNativeReviewFileRo lineIndex += 1; }); + if (sourceRows !== file.rows) { + rows.push(createNoticeRow(file.id, "truncated", TRUNCATED_FILE_NOTICE)); + } rows.push(...noticeRowsForFile(file)); const prepared: PreparedNativeReviewFileRows = { fileId: file.id, filePath: file.path, lineCount: lineRows.length, + truncated: sourceRows !== file.rows, // Comments must not split the source deletion/addition runs used for word matching. rows: addNativeWordDiffRanges(rows), commentTargetsByRowId, @@ -489,6 +501,7 @@ function insertFileComments( const commentsByEndIndex = new Map(); for (const comment of comments) { + if (file.truncated && comment.startIndex >= file.lineCount) continue; const endIndex = Math.min(comment.endIndex, file.lineCount - 1); if (endIndex < 0) continue; const existing = commentsByEndIndex.get(endIndex); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ff02b9db8a59d..d1c18a4ebbcde 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1473,11 +1473,11 @@ export function NewTaskDraftScreen(props: { {environmentControl} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 6be28f295d638..b7fb9a107df7d 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -2,7 +2,7 @@ import { Button } from "../../components/ui/Button"; import { QuestionAttachments } from "./QuestionAttachments"; import type { RuntimeRequestId } from "@t3tools/contracts"; import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; -import { useCallback, useRef } from "react"; +import { useCallback, useRef, useState } from "react"; import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; import Animated, { Easing, @@ -79,6 +79,22 @@ function QuestionText(props: { readonly markdown: string }) { } export function PendingUserInputCard(props: PendingUserInputCardProps) { + const [focusedOption, setFocusedOption] = useState<{ + requestId: RuntimeRequestId; + questionId: string; + optionIndex: number; + } | null>(null); + const focusedQuestion = props.pendingUserInput.questions.find( + (question) => question.id === focusedOption?.questionId, + ); + if ( + focusedOption && + (focusedOption.requestId !== props.pendingUserInput.requestId || + !focusedQuestion || + (props.drafts[focusedQuestion.id]?.customAnswer?.trim().length ?? 0) > 0) + ) { + setFocusedOption(null); + } const questionCount = props.pendingUserInput.questions.length; const canRespond = props.pendingUserInput.responseCapability !== "not_resumable"; const isResponding = props.respondingUserInputId === props.pendingUserInput.requestId; @@ -238,6 +254,19 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { ) : null} {props.pendingUserInput.questions.map((question) => { const draft = props.drafts[question.id]; + const customAnswerActive = (draft?.customAnswer?.trim().length ?? 0) > 0; + const selectedOptionValue = draft?.selectedOptionValues?.at(-1); + const savedOptionMatches = question.options.filter( + (option) => (option.value ?? option.label.trim()) === selectedOptionValue, + ); + const previewOption = customAnswerActive + ? undefined + : focusedOption?.requestId === props.pendingUserInput.requestId && + focusedOption.questionId === question.id + ? question.options[focusedOption.optionIndex] + : savedOptionMatches.length === 1 + ? savedOptionMatches[0] + : undefined; return ( @@ -245,14 +274,16 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { - {question.options.map((option) => { + {question.options.map((option, optionIndex) => { const optionValue = option.value ?? option.label.trim(); const selected = isPendingUserInputOptionSelected(question, draft, optionValue); const description = option.description !== option.label ? option.description : undefined; return ( + onPress={() => { + setFocusedOption({ + requestId: props.pendingUserInput.requestId, + questionId: question.id, + optionIndex, + }); props.onSelectOption( props.pendingUserInput.requestId, question, optionValue, - ) - } + ); + }} > + {previewOption?.preview?.trim() ? ( + + + Preview · {previewOption.label} + + + + ) : null} {question.allowCustomAnswer !== false ? ( - props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) - } + onChangeText={(value) => { + setFocusedOption(null); + props.onChangeCustomAnswer( + props.pendingUserInput.requestId, + question.id, + value, + ); + }} onInputFocusChange={props.onInputFocusChange} /> ) : null} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 705772f054fab..1619612a5aef3 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -170,6 +170,7 @@ export interface ThreadComposerProps { } | null; readonly followUpBehavior: FollowUpBehavior; readonly canSteerActiveTurn: boolean; + readonly hidden: boolean; readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftMedia: () => Promise; @@ -819,6 +820,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps layout={COMPOSER_LAYOUT_TRANSITION} > - {/* - * Hidden (not unmounted) while a user-input request owns the composer slot, so - * composer drafts and editor state survive. - */} {isProviderSubagent ? (