diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 78a0156d1eaa1..cc922de448005 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -125,10 +125,16 @@ of `main` for that work, never the tag-pinned diagnosis clone. - Show the user the complete final issue text and get an explicit yes before posting. Never post without it. - Note at the end of the issue which model and agent produced it. -- If `gh` is not authenticated, offer `gh auth login`, or build a prefilled - https://github.com/pingdotgg/t3code/issues/new URL with title and body query - parameters; print the URL, and open it in their browser only after they - approve. +- Post with `gh issue create --repo pingdotgg/t3code --label via-triage` only + if `gh api repos/pingdotgg/t3code --jq .permissions.triage` prints `true`. +- Otherwise, or if `gh` is not authenticated, build a prefilled + https://github.com/pingdotgg/t3code/issues/new?template=via-triage.yml URL. + The form applies the label whoever submits it. Add `title` plus one + URL-encoded query parameter per form field id: `what-happened`, `diagnosis`, + `steps`, `version`, `environment`, `evidence`, `related`, `workaround` and + `agent`. Keep the full encoded URL below 6,000 characters by shortening any + fields as needed. Give the user any omitted text to paste into the form before + submitting. Print the URL, and open it in their browser only after they approve. - If the user pasted screenshots, remind them to drag the images into the issue after it is created; they cannot be attached from here. diff --git a/AGENTS.md b/AGENTS.md index 27b3f5221458a..faf8eac10fb78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ The most common defect in this repo is a change that works on the path you teste An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: - The first `vp run dev` in a worktree does this for you when its database is missing and the source is at most 500 MiB. Larger sources warn and dev starts with an empty database. `--no-seed` or `T3CODE_DEV_SEED=0` skips seeding. Manual migration still accepts larger sources and may use substantial disk space before pruning. -- Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. +- Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, auth sessions, and links to other environments, so your dev server never runs the developer's agents or reaches their other machines. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. - Copy in, never symlink. Data flows one way: into your sandbox, never back out. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 886ccf3cd798f..68321456683eb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.5", + "version": "0.1.6", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/desktop/resources/installer.nsh b/apps/desktop/resources/installer.nsh index 092f641c3359f..7f277fdcbf9c2 100644 --- a/apps/desktop/resources/installer.nsh +++ b/apps/desktop/resources/installer.nsh @@ -1,3 +1,35 @@ +!include "StrFunc.nsh" +!ifdef BUILD_UNINSTALLER + ${UnStrRep} + !define T3StrRep '${UnStrRep}' +!else + ${StrRep} + !define T3StrRep '${StrRep}' +!endif + +!macro customAppRunningMessages + Push $0 + Push $1 + ${If} $IsPowerShellAvailable == 0 + System::Call 'kernel32::SetEnvironmentVariable(t "T3CODE_INSTALL_DIR", t "$INSTDIR")' + nsExec::ExecToStack `"$PowerShellPath" -NoProfile -NonInteractive -C "Get-CimInstance -ClassName Win32_Process | Where-Object { $$_.ExecutablePath -and [IO.Path]::GetDirectoryName($$_.ExecutablePath) -eq $$env:T3CODE_INSTALL_DIR } | ForEach-Object { [IO.Path]::GetFileNameWithoutExtension($$_.ExecutablePath) } | Where-Object { $$_ -in 'T3 Code (Alpha)', 'T3 Code (Beta)', 'T3 Code (Nightly)' } | Select-Object -First 1 | ForEach-Object { [Console]::Write($$_) }"` + Pop $0 + Pop $1 + System::Call 'kernel32::SetEnvironmentVariable(t "T3CODE_INSTALL_DIR", p 0)' + ${If} $0 == 0 + ${If} $1 == "T3 Code (Alpha)" + ${OrIf} $1 == "T3 Code (Beta)" + ${OrIf} $1 == "T3 Code (Nightly)" + ${T3StrRep} $appRunningMessage $appRunningMessage "${PRODUCT_NAME}" $1 + ${T3StrRep} $appClosingMessage $appClosingMessage "${PRODUCT_NAME}" $1 + ${T3StrRep} $appCannotBeClosedMessage $appCannotBeClosedMessage "${PRODUCT_NAME}" $1 + ${EndIf} + ${EndIf} + ${EndIf} + Pop $1 + Pop $0 +!macroend + !macro customInstall !ifndef DO_NOT_CREATE_START_MENU_SHORTCUT ${IfNot} ${FileExists} "$newStartMenuLink" diff --git a/apps/desktop/src/app/ClerkTokenStore.test.ts b/apps/desktop/src/app/ClerkTokenStore.test.ts new file mode 100644 index 0000000000000..b7ac0e38d5142 --- /dev/null +++ b/apps/desktop/src/app/ClerkTokenStore.test.ts @@ -0,0 +1,189 @@ +// @effect-diagnostics nodeBuiltinImport:off -- These tests assert real POSIX modes on a temporary directory. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { assert, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +vi.mock("node:fs", { spy: true }); + +import { openClerkTokenStorage, type ClerkTokenStorage } from "./ClerkTokenStore.ts"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- Unix mode bits are the assertion; Windows reports them through ACLs. +const describePosix = describe.skipIf(process.platform === "win32"); + +const modeOf = (path: string): number => NodeFS.statSync(path).mode & 0o777; + +function writeLoose(path: string, contents: string): void { + NodeFS.writeFileSync(path, contents, { mode: 0o666 }); + NodeFS.chmodSync(path, 0o666); +} + +async function withStateDir(run: (stateDir: string) => Promise | void): Promise { + const stateDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-clerk-tokens-")); + try { + await run(stateDir); + } finally { + NodeFS.rmSync(stateDir, { recursive: true, force: true }); + } +} + +const idleStorage = (): ClerkTokenStorage => ({ + getItem: async () => null, + setItem: async () => undefined, + removeItem: () => undefined, +}); + +describePosix("ClerkTokenStore", () => { + it("keeps clerk tokens at 0600 inside a 0700 directory", async () => { + await withStateDir(async (stateDir) => { + NodeFS.chmodSync(stateDir, 0o755); + const directory = NodePath.join(stateDir, "clerk"); + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + const tokenPath = NodePath.join(directory, "clerk-tokens.json"); + NodeFS.mkdirSync(directory, { mode: 0o755 }); + NodeFS.chmodSync(directory, 0o755); + writeLoose(legacyPath, '{"__clerk_client_jwt":"enc:existing"}\n'); + + const opened: string[] = []; + const storage = openClerkTokenStorage(stateDir, (cwd) => { + opened.push(cwd); + return { + getItem: async () => { + writeLoose(tokenPath, "from-get"); + return "from-get"; + }, + setItem: (_key, value) => { + writeLoose(tokenPath, value); + }, + removeItem: () => { + NodeFS.rmSync(tokenPath, { force: true }); + }, + }; + }); + + assert.deepEqual(opened, [directory]); + assert.equal(modeOf(stateDir), 0o755); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal( + NodeFS.readFileSync(tokenPath, "utf8"), + '{"__clerk_client_jwt":"enc:existing"}\n', + ); + + assert.equal(await storage.getItem("client"), "from-get"); + assert.equal(modeOf(tokenPath), 0o600); + await storage.setItem("client", "from-set"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "from-set"); + storage.removeItem("client"); + assert.equal(NodeFS.existsSync(tokenPath), false); + + writeLoose(tokenPath, "kept"); + writeLoose(legacyPath, "stale"); + NodeFS.chmodSync(directory, 0o755); + openClerkTokenStorage(stateDir, () => idleStorage()); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept"); + }); + }); + + it("removes the legacy token file when the private copy already exists", async () => { + await withStateDir((stateDir) => { + const directory = NodePath.join(stateDir, "clerk"); + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + const tokenPath = NodePath.join(directory, "clerk-tokens.json"); + NodeFS.mkdirSync(directory, { mode: 0o755 }); + writeLoose(legacyPath, "stale-credential"); + writeLoose(tokenPath, "kept-credential"); + + openClerkTokenStorage(stateDir, () => idleStorage()); + + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept-credential"); + }); + }); + + it("resolves a relative state directory before opening storage", async () => { + await withStateDir(async (stateDir) => { + const relativeStateDir = NodePath.relative(process.cwd(), stateDir); + const opened: string[] = []; + openClerkTokenStorage(relativeStateDir, (directory) => { + opened.push(directory); + return idleStorage(); + }); + + const directory = NodePath.join(stateDir, "clerk"); + assert.equal(NodePath.isAbsolute(relativeStateDir), false); + assert.deepEqual(opened, [directory]); + assert.equal(modeOf(directory), 0o700); + }); + }); + + it("migrates relative state paths from Electron userData, not the working directory", async () => { + await withStateDir((stateDir) => { + const relativeStateDir = NodePath.relative(process.cwd(), stateDir); + const userData = NodePath.join( + stateDir, + "electron", + ...relativeStateDir.split(NodePath.sep).map(() => "profile"), + ); + const legacyStateDir = NodePath.join(userData, relativeStateDir); + const legacyPath = NodePath.join(legacyStateDir, "clerk-tokens.json"); + NodeFS.mkdirSync(legacyStateDir, { recursive: true }); + writeLoose(legacyPath, "existing-session"); + const unrelatedPath = NodePath.join(stateDir, "clerk-tokens.json"); + writeLoose(unrelatedPath, "not-the-electron-session"); + + openClerkTokenStorage(relativeStateDir, idleStorage, userData); + + const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json"); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(NodeFS.readFileSync(unrelatedPath, "utf8"), "not-the-electron-session"); + }); + }); + + it("migrates tokens when the legacy directory is on another filesystem", async () => { + await withStateDir((stateDir) => { + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + writeLoose(legacyPath, "existing-session"); + using _rename = vi.spyOn(NodeFS, "renameSync").mockImplementation(() => { + throw Object.assign(new Error("cross-device link"), { code: "EXDEV" }); + }); + + openClerkTokenStorage(stateDir, idleStorage); + + const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json"); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + }); + }); + + it("does not follow a symlinked clerk directory or token file", async () => { + await withStateDir(async (stateDir) => { + const outsideDirectory = NodePath.join(stateDir, "outside-dir"); + NodeFS.mkdirSync(outsideDirectory, { mode: 0o755 }); + NodeFS.chmodSync(outsideDirectory, 0o755); + NodeFS.symlinkSync(outsideDirectory, NodePath.join(stateDir, "clerk")); + + assert.throws(() => openClerkTokenStorage(stateDir, idleStorage), /must be a real directory/); + assert.equal(modeOf(outsideDirectory), 0o755); + NodeFS.unlinkSync(NodePath.join(stateDir, "clerk")); + + const outsideFile = NodePath.join(stateDir, "outside.json"); + NodeFS.writeFileSync(outsideFile, "secret", { mode: 0o644 }); + NodeFS.chmodSync(outsideFile, 0o644); + NodeFS.symlinkSync(outsideFile, NodePath.join(stateDir, "clerk-tokens.json")); + assert.throws(() => openClerkTokenStorage(stateDir, idleStorage), /must be a regular file/); + assert.equal(modeOf(outsideFile), 0o644); + }); + }); +}); diff --git a/apps/desktop/src/app/ClerkTokenStore.ts b/apps/desktop/src/app/ClerkTokenStore.ts new file mode 100644 index 0000000000000..005736c97cb38 --- /dev/null +++ b/apps/desktop/src/app/ClerkTokenStore.ts @@ -0,0 +1,117 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The Clerk bridge must lock the token file down before startup can yield to Electron's ready event. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +// electron-store chmods every write to 0666; the private directory protects tokens during writes. +const CLERK_TOKEN_DIRECTORY_MODE = 0o700; +const CLERK_TOKEN_FILE_MODE = 0o600; +const CLERK_TOKEN_DIRECTORY_NAME = "clerk"; +const CLERK_TOKEN_FILE_NAME = "clerk-tokens.json"; + +type Awaitable = T | Promise; + +export interface ClerkTokenStorage { + readonly getItem: (key: string) => Awaitable; + readonly setItem: (key: string, value: string) => Awaitable; + readonly removeItem: (key: string) => Awaitable; +} + +function isErrno(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + +function lstatIfExists(path: string): NodeFS.Stats | undefined { + try { + return NodeFS.lstatSync(path); + } catch (error) { + if (isErrno(error, "ENOENT")) return undefined; + throw error; + } +} + +function assertRegularFile(path: string, stat: NodeFS.Stats): void { + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`Clerk token file "${path}" must be a regular file.`); + } +} + +function restrictClerkTokenFile(path: string): void { + const stat = lstatIfExists(path); + if (stat === undefined) return; + assertRegularFile(path, stat); + if ((stat.mode & 0o777) === CLERK_TOKEN_FILE_MODE) return; + NodeFS.chmodSync(path, CLERK_TOKEN_FILE_MODE); +} + +function migrateLegacyClerkTokenFile(stateDir: string, directory: string): void { + const legacyPath = NodePath.join(stateDir, CLERK_TOKEN_FILE_NAME); + const tokenPath = NodePath.join(directory, CLERK_TOKEN_FILE_NAME); + const legacy = lstatIfExists(legacyPath); + if (legacy !== undefined) { + assertRegularFile(legacyPath, legacy); + NodeFS.chmodSync(legacyPath, CLERK_TOKEN_FILE_MODE); + } + + const destination = lstatIfExists(tokenPath); + if (destination === undefined) { + if (legacy === undefined) return; + try { + NodeFS.copyFileSync(legacyPath, tokenPath, NodeFS.constants.COPYFILE_EXCL); + NodeFS.chmodSync(tokenPath, CLERK_TOKEN_FILE_MODE); + NodeFS.unlinkSync(legacyPath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } + return; + } + + assertRegularFile(tokenPath, destination); + NodeFS.chmodSync(tokenPath, CLERK_TOKEN_FILE_MODE); + if (legacy === undefined) return; + try { + NodeFS.unlinkSync(legacyPath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } +} + +function restrictAfter(run: () => Awaitable, tighten: () => void): Awaitable { + let result: Awaitable; + try { + result = run(); + } catch (error) { + tighten(); + throw error; + } + if (result instanceof Promise) return result.finally(tighten); + tighten(); + return result; +} + +/** Opens Clerk token storage in a private directory and keeps the file at `0600`. */ +export function openClerkTokenStorage( + stateDir: string, + createStorage: (directory: string) => ClerkTokenStorage, + userDataPath = ".", +): ClerkTokenStorage { + const absoluteStateDir = NodePath.resolve(stateDir); + const directory = NodePath.join(absoluteStateDir, CLERK_TOKEN_DIRECTORY_NAME); + NodeFS.mkdirSync(directory, { recursive: true, mode: CLERK_TOKEN_DIRECTORY_MODE }); + const stat = NodeFS.lstatSync(directory); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Clerk token directory "${directory}" must be a real directory.`); + } + NodeFS.chmodSync(directory, CLERK_TOKEN_DIRECTORY_MODE); + // electron-store rebases relative paths under Electron userData, not the working directory. + migrateLegacyClerkTokenFile(NodePath.resolve(userDataPath, stateDir), directory); + const tokenPath = NodePath.join(directory, CLERK_TOKEN_FILE_NAME); + restrictClerkTokenFile(tokenPath); + + const storage = createStorage(directory); + const tighten = () => restrictClerkTokenFile(tokenPath); + return { + getItem: (key) => restrictAfter(() => storage.getItem(key), tighten), + setItem: (key, value) => restrictAfter(() => storage.setItem(key, value), tighten), + removeItem: (key) => restrictAfter(() => storage.removeItem(key), tighten), + }; +} diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 34b7286c335ed..8a823cde8db79 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,13 +1,21 @@ // @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account. +import * as NodeFS from "node:fs"; import * as NodeHttp from "node:http"; -import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodePathLayer 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"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { beforeEach, vi } from "vite-plus/test"; +import { afterAll, beforeEach, vi } from "vite-plus/test"; + +const clerkStateDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-clerk-")); +afterAll(() => { + NodeFS.rmSync(clerkStateDir, { recursive: true, force: true }); +}); const { createClerkBridgeMock, requestSingleInstanceLockMock, storageAdapter, storageMock } = vi.hoisted(() => ({ @@ -58,7 +66,7 @@ const layerDesktopClerk = ( listeners = new Map void>(), ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ - stateDir: "/tmp/t3-state", + stateDir: clerkStateDir, isDevelopment, appDataDirectory: "/tmp/app-data", platform, @@ -92,7 +100,7 @@ const layerDesktopClerk = ( return DesktopClerk.layer.pipe( Layer.provide( Layer.mergeAll( - NodePath.layerPosix, + NodePathLayer.layerPosix, Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), Layer.succeed(ElectronShell.ElectronShell, shell), @@ -159,6 +167,10 @@ describe("DesktopClerk", () => { "on:open-url", "createClerkBridge", ]); + assert.deepEqual(storageMock.mock.calls, [ + [{ path: NodePath.join(NodePath.resolve(clerkStateDir), "clerk") }], + ]); + assert.notStrictEqual(createClerkBridgeMock.mock.calls[0]?.[0]?.storage, storageAdapter); }, ); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 992e1fe056937..ef9d2aad56795 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -21,6 +21,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopUserData from "./DesktopUserData.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { openClerkTokenStorage } from "./ClerkTokenStore.ts"; import * as DesktopWebLinks from "./DesktopWebLinks.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -84,9 +85,13 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); -function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { +function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean, userDataPath: string) { return createClerkBridge({ - storage: storage({ path: stateDir }), + storage: openClerkTokenStorage( + stateDir, + (directory) => storage({ path: directory }), + userDataPath, + ), manageSingleInstanceLock: false, passkeys: true, renderer: { @@ -131,7 +136,8 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ - try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), + try: () => + createDesktopClerkBridge(environment.stateDir, environment.isDevelopment, userDataPath), catch: (cause) => new DesktopClerkBridgeInitializationError({ stateDir: environment.stateDir, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index f53cde3e451d7..67ac6a51e65c7 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -238,6 +238,34 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("advertises the current LAN address after the network changes", () => { + const networkInterfaces: Record< + string, + readonly DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[] | undefined + > = { ...lanNetworkInterfaces }; + + return withHarness( + networkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + networkInterfaces.en0 = [{ address: "10.0.0.5", family: "IPv4", internal: false }]; + + const state = yield* serverExposure.getState; + assert.equal(state.advertisedHost, "10.0.0.5"); + assert.equal(state.endpointUrl, "http://10.0.0.5:4173"); + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://10.0.0.5:4173/"], + ); + assert.equal((yield* serverExposure.backendConfig).bindHost, "0.0.0.0"); + }), + ); + }); + it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => withHarness( emptyNetworkInterfaces, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 8d178c605c4a6..288d969d6626c 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -409,6 +409,30 @@ function resolveRuntimeState(input: { }; } +// A network-accessible backend binds every interface, so a new Wi-Fi network +// or DHCP lease changes only the address worth advertising, not the binding. +const withCurrentAdvertisedHost = ( + state: RuntimeState, + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, + advertisedHostOverride: Option.Option, +): RuntimeState => { + if (state.mode !== "network-accessible") { + return state; + } + const override = Option.getOrUndefined(advertisedHostOverride); + const exposure = resolveDesktopServerExposure({ + mode: state.mode, + port: state.port, + networkInterfaces, + ...(override ? { advertisedHostOverride: override } : {}), + }); + return { + ...state, + endpointUrl: Option.fromNullishOr(exposure.endpointUrl), + advertisedHost: Option.fromNullishOr(exposure.advertisedHost), + }; +}; + const tailscaleIpv4AddressKey = ( networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, ): string => @@ -458,7 +482,17 @@ export const make = Effect.gen(function* () { const readNetworkInterfaces = networkInterfaces.read; - const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); + const readCurrentState = (currentNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces) => + Ref.get(stateRef).pipe( + Effect.map((state) => + withCurrentAdvertisedHost(state, currentNetworkInterfaces, config.desktopLanHostOverride), + ), + ); + + const getState = readNetworkInterfaces.pipe( + Effect.flatMap(readCurrentState), + Effect.map(toContractState), + ); const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")( @@ -555,8 +589,8 @@ export const make = Effect.gen(function* () { ); const getAdvertisedEndpoints = Effect.gen(function* () { - const state = yield* Ref.get(stateRef); const currentNetworkInterfaces = yield* readNetworkInterfaces; + const state = yield* readCurrentState(currentNetworkInterfaces); const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: state.port, exposure: toResolvedExposure(state), diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts new file mode 100644 index 0000000000000..30b7f2d7387e3 --- /dev/null +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -0,0 +1,89 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vite-plus/test"; + +const { getSystemLocaleMock, getUserDefaultMock } = vi.hoisted(() => ({ + getSystemLocaleMock: vi.fn(() => "en-GB"), + getUserDefaultMock: vi.fn((_key: string) => false), +})); + +vi.mock("electron", () => ({ + app: { + getSystemLocale: getSystemLocaleMock, + }, + systemPreferences: { + getUserDefault: getUserDefaultMock, + }, +})); + +import * as ElectronApp from "./ElectronApp.ts"; + +describe("ElectronApp", () => { + beforeEach(() => { + getSystemLocaleMock.mockReset(); + getUserDefaultMock.mockReset(); + }); + + it("keeps the macOS region override and hour cycle in the locale tag", () => { + assert.strictEqual(ElectronApp.toSystemLocaleTag("en_GB"), "en-GB"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en-US@rg=nlzzzz", "h12"), "en-NL-u-hc-h12"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en_US@rg=nlzzzz"), "en-NL"); + assert.strictEqual(ElectronApp.toSystemLocaleTag("en-GB", "h23"), "en-GB-u-hc-h23"); + }); + + it("drops ICU keywords Intl cannot read instead of losing the whole locale", () => { + assert.strictEqual(ElectronApp.toSystemLocaleTag("de_DE@calendar=buddhist"), "de-DE"); + }); + + it.effect("follows LC_TIME on Linux when it differs from the language", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const systemLocale = yield* electronApp.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, { + LANG: "en_US.UTF-8", + LC_TIME: "de_DE.UTF-8", + }), + ); + + assert.strictEqual(systemLocale, "de-DE"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("falls back to Chromium's locale on Linux when the time locale is C", () => + Effect.gen(function* () { + getSystemLocaleMock.mockImplementationOnce(() => "fr-FR"); + const electronApp = yield* ElectronApp.ElectronApp; + const systemLocale = yield* electronApp.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, { LANG: "C.UTF-8" }), + ); + + assert.strictEqual(systemLocale, "fr-FR"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); +}); + +it.effect("reads both macOS hour preferences without overriding explicit formats", () => + Effect.gen(function* () { + for (const [preference, expected] of [ + ["AppleICUForce12HourTime", "en-NL-u-hc-h12"], + ["AppleICUForce24HourTime", "en-NL-u-hc-h23"], + ["", "en-NL"], + ]) { + getSystemLocaleMock.mockReturnValue("en_US@rg=nlzzzz"); + getUserDefaultMock.mockImplementation((key) => key === preference); + const locale = yield* ElectronApp.make.systemLocale.pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + ); + assert.strictEqual(locale, expected); + for (const hour12 of [true, false]) { + assert.strictEqual( + new Intl.DateTimeFormat(locale, { hour: "numeric", hour12 }).resolvedOptions().hour12, + hour12, + ); + } + } + }), +); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 04f26b1bced84..d677baa56dce3 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -1,3 +1,4 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -93,6 +94,21 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); +export function toSystemLocaleTag(identifier: string, hourCycle?: "h12" | "h23"): string { + const [base = "", keywords = ""] = identifier.split("@"); + const languageTag = base.replace(/_/g, "-"); + // Intl ignores rg extensions, so the macOS region override must replace the region subtag. + const region = /(?:^|;)rg=([a-z]{2}|\d{3})zzzz(?:;|$)/i.exec(keywords)?.[1]?.toUpperCase(); + try { + return new Intl.Locale(languageTag, { + ...(region ? { region } : {}), + ...(hourCycle ? { hourCycle } : {}), + }).toString(); + } catch { + return languageTag; + } +} + /** @public */ export const make = ElectronApp.of({ metadata: Effect.gen(function* () { @@ -122,8 +138,23 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), - // NSLocale returns en_GB-style tags; Intl requires hyphens. - systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), + systemLocale: Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + let identifier = Electron.app.getSystemLocale(); + let hourCycle: "h12" | "h23" | undefined; + if (platform === "linux") { + const env = yield* HostProcess.Environment; + const timeLocale = (env.LC_ALL || env.LC_TIME || env.LANG)?.split(".")[0]; + if (timeLocale && timeLocale !== "C" && timeLocale !== "POSIX") identifier = timeLocale; + } else if (platform === "darwin") { + if (Electron.systemPreferences.getUserDefault("AppleICUForce24HourTime", "boolean")) { + hourCycle = "h23"; + } else if (Electron.systemPreferences.getUserDefault("AppleICUForce12HourTime", "boolean")) { + hourCycle = "h12"; + } + } + return toSystemLocaleTag(identifier, hourCycle); + }), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; yield* Effect.tryPromise({ diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts new file mode 100644 index 0000000000000..4239bfe4c5bca --- /dev/null +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -0,0 +1,81 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +import type { LinuxColorScheme } from "./LinuxColorScheme.ts"; + +const { nativeTheme } = vi.hoisted(() => ({ + nativeTheme: { themeSource: "system" as "system" | "light" | "dark" }, +})); + +vi.mock("electron", () => ({ nativeTheme })); + +import * as ElectronTheme from "./ElectronTheme.ts"; + +function setup() { + nativeTheme.themeSource = "system"; + const portal: { report: (scheme: LinuxColorScheme) => void; stopped: boolean } = { + report: () => undefined, + stopped: false, + }; + const watch = (onChange: (scheme: LinuxColorScheme) => void) => { + portal.report = onChange; + return { + stop: () => { + portal.stopped = true; + }, + }; + }; + return { portal, watch }; +} + +describe("ElectronTheme", () => { + it.effect("resolves the system appearance from the desktop color scheme as it changes", () => + Effect.gen(function* () { + const { portal, watch } = setup(); + const theme = yield* ElectronTheme.makeWith(watch); + yield* theme.setSource("system"); + + portal.report("dark"); + assert.equal(nativeTheme.themeSource, "dark"); + portal.report("light"); + assert.equal(nativeTheme.themeSource, "light"); + portal.report(null); + assert.equal(nativeTheme.themeSource, "system"); + }).pipe(Effect.scoped), + ); + + it.effect("keeps an explicit appearance over the desktop color scheme", () => + Effect.gen(function* () { + const { portal, watch } = setup(); + const theme = yield* ElectronTheme.makeWith(watch); + portal.report("dark"); + + yield* theme.setSource("light"); + portal.report("dark"); + assert.equal(nativeTheme.themeSource, "light"); + + yield* theme.setSource("system"); + assert.equal(nativeTheme.themeSource, "dark"); + }).pipe(Effect.scoped), + ); + + it.effect("stops following the desktop when the layer is released", () => + Effect.gen(function* () { + const { portal, watch } = setup(); + yield* ElectronTheme.makeWith(watch).pipe(Effect.scoped); + assert.isTrue(portal.stopped); + }), + ); + + it.effect("leaves the system appearance to Electron when the portal is unavailable", () => + Effect.gen(function* () { + setup(); + const theme = yield* ElectronTheme.makeWith(() => { + throw new Error("no session bus"); + }); + yield* theme.setSource("system"); + assert.equal(nativeTheme.themeSource, "system"); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ab0673b817ae2..d31939ba761ab 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -1,4 +1,5 @@ import { DesktopThemeSchema, type DesktopTheme } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -7,6 +8,8 @@ import * as Scope from "effect/Scope"; import * as Electron from "electron"; +import { watchLinuxColorScheme, type LinuxColorScheme } from "./LinuxColorScheme.ts"; + export class ElectronThemeSetSourceError extends Schema.TaggedError()( "ElectronThemeSetSourceError", { @@ -28,30 +31,68 @@ export class ElectronTheme extends Context.Service< } >()("@t3tools/desktop/electron/ElectronTheme") {} -/** @public */ -export const make = ElectronTheme.of({ - shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), - setSource: Effect.fn("ElectronTheme.setSource")(function (theme) { - return Effect.try({ - try: () => { - Electron.nativeTheme.themeSource = theme; - }, - catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), - }); - }), - onUpdated: Effect.fn("ElectronTheme.onUpdated")(function (listener) { - return Effect.acquireRelease( - Effect.suspend(() => { - Electron.nativeTheme.on("updated", listener); - return Effect.void; +type WatchSystemColorScheme = (onChange: (scheme: LinuxColorScheme) => void) => { + readonly stop: () => void; +}; + +export const makeWith = Effect.fn("ElectronTheme.make")(function* ( + watchSystemColorScheme: WatchSystemColorScheme | undefined, +) { + let source: DesktopTheme = Electron.nativeTheme.themeSource; + let systemScheme: LinuxColorScheme = null; + // On Linux, Electron reads the desktop color scheme once and then ignores the settings + // portal (electron/electron#48736), so "system" is resolved from the portal directly. + const apply = () => { + Electron.nativeTheme.themeSource = source === "system" ? (systemScheme ?? source) : source; + }; + + if (watchSystemColorScheme) { + yield* Effect.acquireRelease( + Effect.sync(() => { + try { + return watchSystemColorScheme((scheme) => { + systemScheme = scheme; + apply(); + }); + } catch { + return undefined; + } }), - () => + (watcher) => Effect.sync(() => watcher?.stop()), + ); + } + + return ElectronTheme.of({ + shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), + setSource: Effect.fn("ElectronTheme.setSource")(function (theme) { + return Effect.try({ + try: () => { + source = theme; + apply(); + }, + catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), + }); + }), + onUpdated: Effect.fn("ElectronTheme.onUpdated")(function (listener) { + return Effect.acquireRelease( Effect.suspend(() => { - Electron.nativeTheme.removeListener("updated", listener); + Electron.nativeTheme.on("updated", listener); return Effect.void; }), - ); - }), + () => + Effect.suspend(() => { + Electron.nativeTheme.removeListener("updated", listener); + return Effect.void; + }), + ); + }), + }); +}); + +/** @public */ +export const make = Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + return yield* makeWith(platform === "linux" ? watchLinuxColorScheme : undefined); }); -export const layer = Layer.succeed(ElectronTheme, make); +export const layer = Layer.effect(ElectronTheme, make); diff --git a/apps/desktop/src/electron/LinuxColorScheme.test.ts b/apps/desktop/src/electron/LinuxColorScheme.test.ts new file mode 100644 index 0000000000000..47225f0611b72 --- /dev/null +++ b/apps/desktop/src/electron/LinuxColorScheme.test.ts @@ -0,0 +1,100 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Event-driven fake of the portal transport. +import * as NodeEvents from "node:events"; +import { DBusError, Message, MessageType, Variant, type MessageBus } from "dbus-next"; +import { expect, it, vi } from "vite-plus/test"; + +import { watchLinuxColorScheme, type LinuxColorScheme } from "./LinuxColorScheme.ts"; + +const PATH = "/org/freedesktop/portal/desktop"; +const SETTINGS = "org.freedesktop.portal.Settings"; + +class FakeBus extends NodeEvents.EventEmitter { + calls: Message[] = []; + disconnect = vi.fn(); + readonly settings: { readOne?: unknown; read?: unknown }; + constructor(settings: { readOne?: unknown; read?: unknown }) { + super(); + this.settings = settings; + } + + readonly readOneCalled = Promise.withResolvers(); + + async call(message: Message) { + this.calls.push(message); + if (message.member === "AddMatch") + return new Message({ type: MessageType.METHOD_RETURN, replySerial: "1" }); + if (message.member === "ReadOne") this.readOneCalled.resolve(); + const value = await (message.member === "ReadOne" ? this.settings.readOne : this.settings.read); + if (value === undefined) throw new DBusError("org.freedesktop.DBus.Error.UnknownMethod", ""); + return new Message({ + type: MessageType.METHOD_RETURN, + replySerial: "1", + signature: "v", + body: [value], + }); + } + + changed(namespace: string, key: string, value: number) { + this.emit( + "message", + new Message({ + type: MessageType.SIGNAL, + path: PATH, + interface: SETTINGS, + member: "SettingChanged", + body: [namespace, key, new Variant("u", value)], + }), + ); + } +} + +function watch(bus: FakeBus) { + const schemes: LinuxColorScheme[] = []; + const { ready, stop } = watchLinuxColorScheme( + (scheme) => schemes.push(scheme), + bus as unknown as MessageBus, + ); + return { schemes, ready, stop }; +} + +it("reads the current color scheme and follows portal changes", async () => { + const bus = new FakeBus({ readOne: new Variant("u", 1) }); + const { schemes, ready, stop } = watch(bus); + await ready; + + bus.changed("org.freedesktop.appearance", "color-scheme", 2); + bus.changed("org.freedesktop.appearance", "accent-color", 1); + bus.changed("org.gnome.desktop.interface", "color-scheme", 1); + bus.changed("org.freedesktop.appearance", "color-scheme", 1); + // GNOME's light mode reports "no preference". + bus.changed("org.freedesktop.appearance", "color-scheme", 0); + bus.changed("org.freedesktop.appearance", "color-scheme", 3); + expect(schemes).toEqual(["dark", "light", "dark", "light", null]); + + stop(); + bus.changed("org.freedesktop.appearance", "color-scheme", 1); + expect(schemes).toEqual(["dark", "light", "dark", "light", null]); + expect(bus.disconnect).toHaveBeenCalledOnce(); +}); + +it("falls back to the nested Read reply on portals without ReadOne", async () => { + const bus = new FakeBus({ read: new Variant("v", new Variant("u", 2)) }); + const { schemes, ready } = watch(bus); + await ready; + + expect(schemes).toEqual(["light"]); + expect(bus.calls.map((call) => call.member)).toEqual(["AddMatch", "ReadOne", "Read"]); +}); + +it("keeps a change that arrives while the initial read is pending", async () => { + const reply = Promise.withResolvers(); + const bus = new FakeBus({ readOne: reply.promise }); + const { schemes, ready } = watch(bus); + + await bus.readOneCalled.promise; + bus.changed("org.freedesktop.appearance", "color-scheme", 1); + reply.resolve(new Variant("u", 0)); + await ready; + + expect(schemes).toEqual(["dark"]); +}); diff --git a/apps/desktop/src/electron/LinuxColorScheme.ts b/apps/desktop/src/electron/LinuxColorScheme.ts new file mode 100644 index 0000000000000..8b88fd5655aa8 --- /dev/null +++ b/apps/desktop/src/electron/LinuxColorScheme.ts @@ -0,0 +1,91 @@ +import { Message, MessageType, Variant, sessionBus, type MessageBus } from "dbus-next"; + +const PORTAL = "org.freedesktop.portal.Desktop"; +const PATH = "/org/freedesktop/portal/desktop"; +const SETTINGS = "org.freedesktop.portal.Settings"; +const DBUS = "org.freedesktop.DBus"; +const NAMESPACE = "org.freedesktop.appearance"; +const KEY = "color-scheme"; + +export type LinuxColorScheme = "dark" | "light" | null; + +// The portal reports 1 for prefer-dark, 2 for prefer-light and 0 for no preference. GNOME's +// light mode is 0, and the spec asks apps to use their default then, so only 1 is dark. +// The deprecated Read method nests the value in a second variant. +function decodeColorScheme(value: unknown): LinuxColorScheme { + let scheme = value; + while (scheme instanceof Variant) scheme = scheme.value; + return scheme === 1 ? "dark" : scheme === 0 || scheme === 2 ? "light" : null; +} + +const settingsCall = (member: "ReadOne" | "Read") => + new Message({ + destination: PORTAL, + path: PATH, + interface: SETTINGS, + member, + signature: "ss", + body: [NAMESPACE, KEY], + }); + +/** + * Follows the desktop color scheme through the XDG settings portal. Without a session bus or + * portal it reports nothing, so Chromium's own resolution stays in charge. + */ +export function watchLinuxColorScheme( + onChange: (scheme: LinuxColorScheme) => void, + bus: MessageBus = sessionBus(), +) { + let closed = false; + let changed = false; + const report = (value: unknown) => { + if (!closed) onChange(decodeColorScheme(value)); + }; + const onMessage = (message: Message) => { + if ( + message.type !== MessageType.SIGNAL || + message.path !== PATH || + message.interface !== SETTINGS || + message.member !== "SettingChanged" + ) + return; + const [namespace, key, value] = message.body; + if (namespace !== NAMESPACE || key !== KEY) return; + changed = true; + report(value); + }; + const ignore = () => undefined; + + bus.on("error", ignore); + bus.on("message", onMessage); + const ready = (async () => { + await bus.call( + new Message({ + destination: DBUS, + path: "/org/freedesktop/DBus", + interface: DBUS, + member: "AddMatch", + signature: "s", + body: [ + `type='signal',interface='${SETTINGS}',member='SettingChanged',path='${PATH}',arg0='${NAMESPACE}',arg1='${KEY}'`, + ], + }), + ); + // ReadOne needs Settings portal version 2; older portals only offer Read. + const reply = await bus + .call(settingsCall("ReadOne")) + .catch(() => bus.call(settingsCall("Read"))); + // The portal answers reads through its backend asynchronously, so a change signal can + // overtake this reply; the reply then carries the older value. + if (reply && !changed) report(reply.body[0]); + })().catch(ignore); + + return { + ready, + stop: () => { + closed = true; + bus.removeListener("message", onMessage); + bus.disconnect(); + }, + }; +} diff --git a/apps/desktop/src/permissions/MacSettingsWindow.test.ts b/apps/desktop/src/permissions/MacSettingsWindow.test.ts index d07b4f5f9a76d..ce0d3509d68bd 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.test.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.test.ts @@ -1,4 +1,5 @@ import * as NodeEvents from "node:events"; +import * as NodeVM from "node:vm"; import { beforeEach, expect, it, vi } from "vite-plus/test"; import { watchMacSettingsWindow } from "./MacSettingsWindow.ts"; @@ -42,3 +43,29 @@ it("distinguishes unavailable tracking from a valid absent window", () => { expect(unavailable).toHaveBeenCalledTimes(2); stop(); }); +it("polls while its parent lives and stops after reparenting to launchd", () => { + watchMacSettingsWindow(vi.fn(), vi.fn())(); + const script = mocks.spawn.mock.lastCall?.[1][3]; + const imports = new Set(); + const writeData = vi.fn(); + const poll = vi.fn(() => ({ count: 0 })); + const sleep = vi.fn(() => { + if (sleep.mock.calls.length > 2) throw new Error("tracker kept polling after parent exit"); + }); + const bridge = Object.assign((line: string) => ({ dataUsingEncoding: () => line }), { + getppid: () => { + if (!imports.has("unistd")) throw new Error("unistd bridge unavailable"); + return sleep.mock.calls.length < 2 ? 42 : 1; + }, + NSRunningApplication: { runningApplicationsWithBundleIdentifier: poll }, + NSFileHandle: { fileHandleWithStandardOutput: { writeData } }, + NSThread: { sleepForTimeInterval: sleep }, + NSUTF8StringEncoding: 4, + }); + NodeVM.runInNewContext(script + "\nrun();", { + ObjC: { import: (name: string) => imports.add(name) }, + $: bridge, + }); + expect(poll).toHaveBeenCalledTimes(2); + expect(writeData.mock.calls).toEqual([["null\n"]]); +}); diff --git a/apps/desktop/src/permissions/MacSettingsWindow.ts b/apps/desktop/src/permissions/MacSettingsWindow.ts index e5acc09abbf92..5967d15de97a4 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.ts @@ -20,9 +20,10 @@ const decodeSettingsWindow = Schema.decodeUnknownSync(Schema.fromJsonString(Sett const SETTINGS_WINDOW_SCRIPT = ` ObjC.import("CoreGraphics"); ObjC.import("AppKit"); +ObjC.import("unistd"); function run() { let previous = ""; - while (true) { + while ($.getppid() !== 1) { const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.systempreferences"); let result = null; if (apps.count > 0) { diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts index 6fc5b7ae9faa1..69f21285e1965 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts @@ -17,6 +17,7 @@ import { unwrapWindowsDpapiKey, } from "./ChromiumKeys.ts"; import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; +import { BROWSER_IMPORT_SOURCES } from "./Sources.ts"; const { getPassword } = vi.hoisted(() => ({ getPassword: vi.fn<() => string | null>() })); @@ -146,12 +147,24 @@ describe("Linux Chromium secrets", () => { ), ); - it.effect("preserves trailing whitespace in the stored secret", () => - Effect.gen(function* () { - const secret = yield* readLinuxSecret("chrome"); - expect(secret).toBe("linux-secret \t\n"); - }).pipe(Effect.provide(layerHelper({ stdout: "linux-secret \t\n" }))), - ); + it.effect("derives Vivaldi's Linux key while preserving whitespace in the stored secret", () => { + let captured: CapturedCommand | undefined; + return Effect.gen(function* () { + const source = BROWSER_IMPORT_SOURCES.find((source) => source.id === "vivaldi")!; + const keys = yield* resolveChromiumKeys({ + platform: "linux", + keychainService: source.keychainService, + keychainAccount: source.keychainAccount, + linuxSecretApplication: source.linuxSecretApplication, + }); + expect(captured?.args).toEqual(["chrome"]); + expect(keys.cbcV11?.toString("hex")).toBe("95aa769d6a5beca14d5d90ae655db112"); + }).pipe( + Effect.provide( + layerHelper({ stdout: "linux-secret \t\n", capture: (value) => (captured = value) }), + ), + ); + }); it.effect("drains stdout and stderr concurrently", () => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 14c4bca77b7e6..3f94269bfe273 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -109,7 +109,8 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray { }).pipe(Effect.provide(layer)), ); - it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => + it.effect("grants preview permissions through both the request and check handlers", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; const partition = yield* browserSessions.getPartition("scope-a"); @@ -168,7 +168,12 @@ describe("BrowserSession", () => { return granted; }; - for (const permission of ["clipboard-sanitized-write", "geolocation", "fullscreen"]) { + for (const permission of [ + "clipboard-sanitized-write", + "geolocation", + "fullscreen", + "loopback-network", + ]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); assert.isTrue( checkHandler(null, permission) as boolean, @@ -179,7 +184,13 @@ describe("BrowserSession", () => { // `clipboard-write` is not a real Electron permission — the async write API // uses `clipboard-sanitized-write` — so the stale name must not be granted, // and unrelated permissions stay denied. - for (const permission of ["clipboard-write", "notifications", "midi"]) { + for (const permission of [ + "clipboard-write", + "notifications", + "midi", + "local-network", + "local-network-access", + ]) { assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`); assert.isFalse( checkHandler(null, permission) as boolean, diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index e467c370d0f5b..f8f23aca6afa7 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -24,6 +24,8 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "geolocation", // DesktopWindow confines HTML fullscreen to the guest's webview. "fullscreen", + // Loopback requests already succeed in Electron 44; permission-aware sign-in widgets query first. + "loopback-network", // Deny local-fonts to untrusted previews: it exposes installed-font fingerprints and file bytes. // Deny notifications: pages could impersonate T3 Code; app notifications use the default session. ]); diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 1c1f9dc078ca0..a2589edf8f7b2 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -14,6 +14,9 @@ const makeTarget = ( setDownloadDirectory, }); +const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) => + createCdpRelayConnection(target, write, () => {}); + // Each relay reply resolves after a few microtasks; one macrotask lets them all land. // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -21,9 +24,7 @@ const settle = () => new Promise((resolve) => setImmediate(resolve)); describe("CDP relay", () => { it("announces only the tab's page, under its real target id", async () => { const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(vi.fn()), (raw) => written.push(JSON.parse(raw))); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); relay.receive(JSON.stringify({ id: 2, method: "Target.createTarget", params: {} })); await settle(); @@ -51,9 +52,7 @@ describe("CDP relay", () => { new Promise((resolve) => pending.push(() => resolve({ method }))), ); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive( JSON.stringify({ id: 5, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }), ); @@ -76,7 +75,7 @@ describe("CDP relay", () => { it("resumes paused child frames, so cross-site iframes run", async () => { const send = vi.fn(async () => ({})); - const relay = createCdpRelayConnection(makeTarget(send), () => {}); + const relay = makeRelay(makeTarget(send), () => {}); relay.receive( JSON.stringify({ id: 1, @@ -102,9 +101,7 @@ describe("CDP relay", () => { it("acknowledges Playwright's focus emulation without forcing it onto the guest", async () => { const send = vi.fn(async () => ({})); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive( JSON.stringify({ id: 1, @@ -120,9 +117,7 @@ describe("CDP relay", () => { it("routes tab events to the page session once the page is announced", async () => { const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(vi.fn()), (raw) => written.push(JSON.parse(raw))); relay.event("Page.loadEventFired", {}, undefined); expect(written).toEqual([]); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); @@ -140,9 +135,7 @@ describe("CDP relay extra sessions", () => { it("gives a second page session the page's commands and events", async () => { const send = vi.fn(async () => ({})); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); relay.receive(JSON.stringify({ id: 2, method: "Target.attachToBrowserTarget" })); await settle(); @@ -182,7 +175,7 @@ describe("CDP relay downloads", () => { const send = vi.fn(async () => ({})); const directories: Array = []; const written: Array> = []; - const relay = createCdpRelayConnection( + const relay = makeRelay( makeTarget(send, (directory) => directories.push(directory)), (raw) => written.push(JSON.parse(raw)), ); @@ -213,3 +206,58 @@ describe("CDP relay downloads", () => { ]); }); }); + +describe("CDP relay payload budget", () => { + // 2,048 references to one 40 KiB string: about 80 MiB once serialized, little memory here. + const oversized = Array(2048).fill("x".repeat(40 * 1024)); + + it("fails only an oversized reply and still returns a large screenshot", async () => { + const screenshot = "A".repeat(16 * 1024 * 1024); + const send = vi.fn(async (method: string) => + method === "DOM.getDocument" ? { nodes: oversized } : { data: screenshot }, + ); + const written: Array> = []; + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); + relay.receive( + JSON.stringify({ id: 1, method: "DOM.getDocument", sessionId: "t3-preview-page" }), + ); + relay.receive( + JSON.stringify({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }), + ); + await settle(); + + const replies = written.map(({ id, error, result }) => ({ + id, + error, + size: (result as { data?: string } | undefined)?.data?.length, + })); + expect(replies).toEqual([ + { id: 1, error: { code: -32000, message: "CDP payload too large" }, size: undefined }, + { id: 2, error: undefined, size: screenshot.length }, + ]); + }); + + it.each([ + { args: oversized }, + { args: "\u0001".repeat(12 * 1024 * 1024) }, + { args: Array(2_700_000).fill(-0.0000010000000000000002) }, + ])("closes instead of dropping an oversized event (%#)", async ({ args }) => { + const written: Array> = []; + const close = vi.fn(); + const relay = createCdpRelayConnection( + makeTarget(vi.fn()), + (raw) => written.push(JSON.parse(raw)), + close, + ); + relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); + await settle(); + written.length = 0; + + relay.event("Runtime.consoleAPICalled", { args }, ""); + relay.event("Page.loadEventFired", { timestamp: 1 }, ""); + + relay.receive(JSON.stringify({ id: 2, method: "Target.setAutoAttach" })); + expect(close).toHaveBeenCalledOnce(); + expect(written.map((message) => message["method"])).toEqual([]); + }); +}); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 948bc361f5d92..d52b0a3b2ab55 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -36,6 +36,29 @@ const isCommand = (value: unknown): value is CdpCommand => typeof (value as { id?: unknown }).id === "number" && typeof (value as { method?: unknown }).method === "string"; +// CDP values are plain JSON data, so their serialized size can be bounded before +// JSON.stringify allocates it. A string counts at its length unless it holds a +// character JSON may escape, which can take six code units. +const MAX_CDP_PAYLOAD_SIZE = 64 * 1024 * 1024; +const MAY_ESCAPE = /[^\x20\x21\x23-\x5b\x5d-\ud7ff\ue000-\uffff]/; +const serializedSize = (text: string) => + (MAY_ESCAPE.test(text) ? text.length * 6 : text.length) + 3; + +const fitsPayloadBudget = (message: unknown) => { + let remaining = MAX_CDP_PAYLOAD_SIZE; + const visit = (value: unknown): boolean => { + remaining -= typeof value === "string" ? serializedSize(value) : 26; + if (remaining < 0) return false; + if (typeof value !== "object" || value === null) return true; + if (Array.isArray(value)) return value.every(visit); + return Object.entries(value).every(([key, item]) => { + remaining -= serializedSize(key); + return visit(item); + }); + }; + return visit(message); +}; + const BROWSER_CONTEXT_ID = "t3-preview"; const ANNOUNCE = Symbol("announce"); const PAGE_SESSION_ID = "t3-preview-page"; @@ -50,8 +73,30 @@ const ACKNOWLEDGED = new Set([ export function createCdpRelayConnection( target: CdpRelayTarget, write: (message: string) => void, + close: () => void, ): CdpRelayConnection { - const send = (message: Record) => write(JSON.stringify(message)); + let closed = false; + const send = (message: Record) => { + if (closed) return; + if (fitsPayloadBudget(message)) { + write(JSON.stringify(message)); + return; + } + // An error reply fails only that command. A dropped event would leave + // Playwright out of step with the page, so the relay closes instead. + if (typeof message["id"] === "number") { + write( + JSON.stringify({ + id: message["id"], + error: { code: -32000, message: "CDP payload too large" }, + ...(message["sessionId"] === undefined ? {} : { sessionId: message["sessionId"] }), + }), + ); + return; + } + closed = true; + close(); + }; let attached = false; /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); @@ -151,6 +196,7 @@ export function createCdpRelayConnection( return { receive: (raw) => { + if (closed) return; let command: unknown; try { command = JSON.parse(raw); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 08fefd8d733f5..3168788bebe9b 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron debugger. -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; import { DesktopBrowserEvent } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -968,4 +968,73 @@ describe("DesktopBrowserHost", () => { expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(false); }), ); + + it.effect("detaches only the tab whose CDP event is too large to relay", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const tabs = [makeDebuggee({ deferFocus: true }), makeDebuggee()]; + const send = vi.spyOn(tabs[0]!.tab.debugger, "sendCommand"); + const keys = [key, { threadId: "thread-2", tabId: "tab-2" }]; + const events = yield* Queue.unbounded(); + yield* host.events.pipe( + Stream.runForEach((line) => + Queue.offer(events, decodeEvent(new TextDecoder().decode(line))), + ), + Effect.forkScoped, + ); + for (const [index, tabKey] of keys.entries()) { + host.attach(tabKey, tabs[index]!.tab); + expect(yield* Queue.take(events)).toEqual({ type: "attached", ...tabKey }); + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...tabKey, + message: encodeJson({ id: 1, method: "Target.setAutoAttach" }), + }), + ); + // The command's reply and the page announcement. + yield* Queue.takeN(events, 2); + } + + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...key, + message: encodeJson({ + id: 2, + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, + sessionId: "t3-preview-page", + }), + }), + ); + yield* command(host, 3, "Page.startScreencast"); + tabs[0]!.emit("Runtime.consoleAPICalled", { + args: Array(2048).fill("x".repeat(40 * 1024)), + }); + tabs[1]!.emit("Page.loadEventFired", { timestamp: 1 }); + const [detached, next] = yield* Queue.takeN(events, 2); + + const { type, threadId, tabId } = detached!; + expect({ type, threadId, tabId }).toEqual({ type: "detached", ...key }); + expect(send).toHaveBeenCalledWith("Target.setAutoAttach", { + autoAttach: false, + waitForDebuggerOnStart: false, + flatten: true, + }); + tabs[0]!.settleFocus(); + yield* settle; + expect(send).not.toHaveBeenCalledWith("Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + }); + expect(send).toHaveBeenCalledWith("Page.stopScreencast"); + expect(tabs[0]!.focusEmulation).toEqual([true, false]); + expect(next).toMatchObject({ type: "cdp", ...keys[1] }); + expect(JSON.parse((next as { message: string }).message)).toMatchObject({ + method: "Page.loadEventFired", + }); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 30f252e3a677e..5a2b5463fc674 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -329,7 +329,7 @@ export const make = Effect.gen(function* () { const forward = (current?: () => boolean) => { const change = tab.focusChange; const run = () => - current === undefined || current() + tab.relay === relay && (current === undefined || current()) ? send(method, params, sessionId) : Promise.reject(new Error("The page this screenshot was for has moved on.")); return change === null ? run() : change.then(run); @@ -372,6 +372,24 @@ export const make = Effect.gen(function* () { emit({ type: "cdp", ...tab.key, message }); } }, + // Announcing the tab again would reconnect Playwright, and the page could + // replay the same oversized event, so the tab stays detached. + () => { + if (tab.relay !== relay || tabs.get(keyOf(tab.key)) !== tab) return; + // Disabling auto-attach detaches child sessions and releases their debugger pauses. + void debuggee + .sendCommand("Target.setAutoAttach", { + autoAttach: false, + waitForDebuggerOnStart: false, + flatten: true, + }) + .catch(() => {}); + if (tab.screencasts.size > 0) { + void debuggee.sendCommand("Page.stopScreencast").catch(() => {}); + } + setFocusEmulation(tab, false); + detach(tab.key); + }, ); tab.relay = relay; return relay; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index f19079e46bce4..41ac8abd37822 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -86,6 +86,12 @@ describe("previewWindowOpenAction", () => { ).toBe("popup"); }); + it("opens HTTP links in preview tabs", () => { + for (const disposition of ["foreground-tab", "background-tab"] as const) { + expect(PreviewManager.previewWindowOpenAction(details({ disposition }))).toBe("new-tab"); + } + }); + it("keeps a form POST with a body on the in-place path", () => { const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; expect( @@ -98,15 +104,32 @@ describe("previewWindowOpenAction", () => { it("does not hand a window to schemes that cannot be hardened", () => { for (const url of [ - "javascript:alert(1)", - "file:///etc/passwd", "vscode://vscode-remote/ssh-remote+box/tmp", - "not a url", + "blob:https://example.com/0b6c6a5e-55d4-4b4c-9d47-8f1b0c1d2e3f", ]) { expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate"); } }); + it("denies local and inline schemes that no page could open in place itself", () => { + for (const url of [ + "data:text/html,

hi

", + "DaTa:text/html,

hi

", + "file:///etc/passwd", + "javascript:alert(1)", + "view-source:https://example.com", + "chrome://gpu", + "about:srcdoc", + "devtools://devtools/bundled/inspector.html", + "filesystem:https://example.com/temporary/page.html", + "not a url", + ]) { + for (const disposition of ["new-window", "foreground-tab", "background-tab"] as const) { + expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny"); + } + } + }); + it("denies a blank window instead of blanking the opener", () => { for (const url of ["about:blank", ""]) { for (const disposition of ["new-window", "foreground-tab"] as const) { @@ -2201,6 +2224,48 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("shows picture-in-picture and keeps capturing after stalled captures", () => + withManager((manager) => + Effect.gen(function* () { + const image = { + toJPEG: () => Buffer.from("preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + }; + const stalledInitial = Promise.withResolvers(); + const stalledLoop = Promise.withResolvers(); + const capturePage = vi + .fn<() => Promise>() + .mockImplementationOnce(() => stalledInitial.promise) + .mockImplementationOnce(() => stalledLoop.promise) + .mockResolvedValue(image); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + yield* manager.createTab("tab_pip_stalled_capture"); + yield* manager.registerWebview("tab_pip_stalled_capture", 42); + + try { + const open = yield* manager + .openPictureInPicture("tab_pip_stalled_capture") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(1_000); + expect(pictureInPictureWindow.showInactive).toHaveBeenCalledOnce(); + yield* Fiber.join(open); + + expect(send).not.toHaveBeenCalled(); + yield* TestClock.adjust(500); + expect(send).toHaveBeenCalled(); + } finally { + // Release stalled native calls after a failing assertion against unchanged base. + stalledInitial.resolve(image); + stalledLoop.resolve(image); + } + }), + ), + ); + effectIt.effect("rejects picture-in-picture when its webview changes during initialization", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 036c0d4afd932..dc05f1a8c82e5 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -423,13 +423,17 @@ type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect { - try { - return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol); - } catch { - return false; - } -}; +// Main-process loadURL bypasses Chromium's renderer-side restrictions on local and inline URLs. +const IN_PLACE_DENIED_PROTOCOLS = new Set([ + "about:", + "chrome:", + "data:", + "devtools:", + "file:", + "filesystem:", + "javascript:", + "view-source:", +]); /** Popups bypass will-attach-webview hardening and would inherit the picker's contextIsolation:false. */ const POPUP_WINDOW_OPTIONS = { @@ -440,15 +444,19 @@ const POPUP_WINDOW_OPTIONS = { }, } satisfies Electron.BrowserWindowConstructorOptions; -/** OAuth needs a surviving popup opener; unhardenable schemes and form POSTs must stay in place. */ +/** + * OAuth needs a surviving popup opener. `blob:`, app links (behind the session's `openExternal` + * prompt) and form POSTs stay in place. + */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; readonly postBody?: Electron.PostBody; }): "popup" | "new-tab" | "navigate" | "deny" => { + const protocol = URL.parse(details.url)?.protocol; // MSAL redirects on a denied blank popup; navigating the tab would destroy that fallback. - if (details.url === "" || details.url === "about:blank") return "deny"; - if (!isPopupUrl(details.url)) return "navigate"; + if (protocol === undefined || IN_PLACE_DENIED_PROTOCOLS.has(protocol)) return "deny"; + if (!POPUP_PROTOCOLS.has(protocol)) return "navigate"; if (details.disposition === "new-window") return "popup"; return !details.postBody && (details.disposition === "foreground-tab" || details.disposition === "background-tab") @@ -2426,13 +2434,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!captureSession?.consumers.has("picture-in-picture") || captureSession.scope === null) return; const wc = yield* requireWebContents(tabId); - const image = yield* attemptPromise( - { - operation: "frameCapture.capturePage", - tabId, - webContentsId: wc.id, - }, - () => wc.capturePage(), + const captureContext = { operation: "frameCapture.capturePage", tabId, webContentsId: wc.id }; + const image = yield* attemptPromise(captureContext, () => wc.capturePage()).pipe( + Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail(new PreviewOperationError({ ...captureContext, cause })), + }), ); const currentCaptureSession = yield* Effect.all( [SynchronizedRef.get(frameCaptureSessionsRef), SynchronizedRef.get(tabsRef)], diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 7c63a8b9aadef..fe166f5d099a0 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -1,5 +1,6 @@ import { it as effectIt } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { parseKeybindingShortcut } from "@t3tools/shared/keybindings"; import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; @@ -9,6 +10,7 @@ import { compactAccessibilityTree, findAccessibleWindow, isWaylandSession, + toElectronAccelerator, } from "./snapShot.ts"; describe("accessibleWindowText", () => { @@ -448,6 +450,21 @@ describe("findAccessibleWindow", () => { }); }); +describe("toElectronAccelerator", () => { + // The settings recorder stores shortcuts through parseKeybindingShortcut, which lowercases keys. + it.each([ + ["mod+shift+arrowup", "CommandOrControl+Shift+Up"], + ["mod+shift+arrowdown", "CommandOrControl+Shift+Down"], + ["ctrl+alt+arrowleft", "Control+Alt+Left"], + ["ctrl+alt+arrowright", "Control+Alt+Right"], + ["mod+shift+esc", "CommandOrControl+Shift+Esc"], + ["mod+shift+space", "CommandOrControl+Shift+Space"], + ["mod+shift+2", "CommandOrControl+Shift+2"], + ])("maps a recorded %s to an Electron accelerator", (recorded, accelerator) => { + expect(toElectronAccelerator(parseKeybindingShortcut(recorded)!)).toBe(accelerator); + }); +}); + describe("isWaylandSession", () => { effectIt.effect( "falls back to a live runtime directory socket when session variables are stripped", diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 872f99e1d6a72..8be137cb32a46 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -539,14 +539,15 @@ function findBrowserTitleMatch( return browserMatches.length === 1 ? browserMatches[0] : undefined; } +// Keyed by the lowercase tokens parseKeybindingShortcut stores; Electron has no `ArrowUp` token. const ELECTRON_KEY_NAMES: Readonly> = { " ": "Space", "+": "Plus", - ArrowDown: "Down", - ArrowLeft: "Left", - ArrowRight: "Right", - ArrowUp: "Up", - Escape: "Esc", + arrowdown: "Down", + arrowleft: "Left", + arrowright: "Right", + arrowup: "Up", + escape: "Esc", }; export function sameSnapShotShortcut(left: SnapShotShortcut, right: SnapShotShortcut): boolean { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index d88838439dba7..30db8274c208f 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -232,4 +232,45 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { omittedReleaseCount: 0, }); }); + + it("ignores the Full Changelog paragraph that ends GitHub's generated notes", () => { + const fullChangelog = (from: string, to: string) => + `

Full Changelog: v${from}...v${to}

`; + const result = normalizeDesktopUpdateReleaseNotes( + [ + { + version: "0.1.3-nightly.20261024.3604", + note: fullChangelog("0.1.3-nightly.20261023.3592", "0.1.3-nightly.20261024.3604"), + }, + { + version: "0.1.3-nightly.20261023.3592", + note: + "

What's Changed

\n
    \n" + + '
  • fix(web): A by @a in #1
  • \n' + + '
  • fix(web): B by @b in #2
  • \n' + + "
  • Full Changelog: links open in the browser in #3
  • \n" + + "
\n" + + fullChangelog("0.1.3-nightly.20261023.3579", "0.1.3-nightly.20261023.3592"), + }, + ], + "0.1.3-nightly.20261024.3604", + "nightly", + ); + + expect(result).toEqual({ + releaseNotes: [ + { + version: "0.1.3-nightly.20261023.3592", + items: [ + "Full Changelog: links open in the browser in #3", + "fix(web): B by @b in #2", + "fix(web): A by @a in #1", + ], + releasedAt: "2026-10-23", + totalItems: 3, + }, + ], + omittedReleaseCount: 0, + }); + }); }); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 2704563cfc0b8..1fb81088175c1 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -86,7 +86,9 @@ function isIgnoredReleaseNoteLine(line: string): boolean { normalized === "what's changed" || normalized === "whats changed" || normalized.startsWith("compare: ") || - normalized.includes("/compare/") + normalized.includes("/compare/") || + // The feed's HTML footer loses its compare URL to stripMarkup, leaving only the range. + /^full changelog: \S+\.\.\.\S+$/.test(normalized) ); } diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 8b6ab7c7d8a5e..0dcb2b85ac2c5 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -78,6 +78,12 @@ function makeFakeBrowserWindow() { zoomLevel = level; }), isLoadingMainFrame: vi.fn(() => false), + navigationHistory: { + canGoBack: vi.fn(() => true), + canGoForward: vi.fn(() => true), + goBack: vi.fn(), + goForward: vi.fn(), + }, on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); }), @@ -136,6 +142,7 @@ function makeFakeBrowserWindow() { reload: webContents.reload, reloadIgnoringCache: webContents.reloadIgnoringCache, toggleDevTools: webContents.toggleDevTools, + navigationHistory: webContents.navigationHistory, send: webContents.send, setZoomLevel: webContents.setZoomLevel, setWindowButtonPosition: window.setWindowButtonPosition, @@ -472,6 +479,45 @@ describe("DesktopWindow", () => { ); }); + it("rejects renderer navigations whose origin only serializes as null", () => { + for (const applicationUrl of ["t3code://app/", "t3code-dev://app/"]) { + assert.isTrue( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl, + navigationUrl: `${applicationUrl}threads?id=1#latest`, + }), + applicationUrl, + ); + } + for (const navigationUrl of [ + "t3code://other/", + "t3code://app.example/", + "t3code://app:8080/", + "t3code://user@other/", + "t3code:app", + "t3code-dev://app/", + "file:///etc/passwd", + "data:text/html,

hi

", + "javascript:alert(1)", + "about:blank", + "blob:t3code://app/0b5e2c4a", + ]) { + assert.isFalse( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "t3code://app/", + navigationUrl, + }), + navigationUrl, + ); + } + assert.isFalse( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "data:text/html,app", + navigationUrl: "data:text/html,other", + }), + ); + }); + it.effect( "opens and reopens the window without backend readiness when local execution is disabled", () => @@ -895,6 +941,39 @@ describe("DesktopWindow", () => { }), ); + it.effect("maps macOS horizontal swipes to available history navigation", () => + 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 swipe = fakeWindow.windowListeners.get("swipe"); + assert.isDefined(swipe); + if (!swipe) return; + const history = fakeWindow.navigationHistory; + + swipe({}, "left"); + assert.equal(history.goBack.mock.calls.length, 1); + assert.equal(history.goForward.mock.calls.length, 0); + swipe({}, "right"); + assert.equal(history.goForward.mock.calls.length, 1); + + swipe({}, "up"); + swipe({}, "down"); + history.canGoBack.mockReturnValue(false); + history.canGoForward.mockReturnValue(false); + swipe({}, "left"); + swipe({}, "right"); + assert.equal(history.goBack.mock.calls.length, 1); + assert.equal(history.goForward.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("persists the current main window bounds before the window closes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -973,6 +1052,17 @@ describe("DesktopWindow", () => { if (!willNavigate) { return yield* Effect.die("will-navigate listener was not registered"); } + for (const [url, expectedPrevented] of [ + ["t3code-dev://app/threads?id=1#latest", false], + ["t3code-dev://other/", true], + ["t3code://app/", true], + ] as const) { + const preventDefault = vi.fn(); + willNavigate({ preventDefault }, url); + assert.equal(preventDefault.mock.calls.length, expectedPrevented ? 1 : 0, url); + } + assert.deepEqual(openedExternalUrls, []); + let prevented = false; willNavigate( { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index c1c0cf5446cd2..10801089841d1 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -183,12 +183,19 @@ function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } -function isSameOriginRendererNavigation(input: { +export function isSameOriginRendererNavigation(input: { readonly applicationUrl: string; readonly navigationUrl: string; }): boolean { try { - return new URL(input.applicationUrl).origin === new URL(input.navigationUrl).origin; + const applicationUrl = new URL(input.applicationUrl); + const navigationUrl = new URL(input.navigationUrl); + // Node serializes custom-scheme origins as "null", even for different hosts and schemes. + return ( + applicationUrl.host !== "" && + navigationUrl.protocol === applicationUrl.protocol && + navigationUrl.host === applicationUrl.host + ); } catch { return false; } @@ -678,6 +685,12 @@ export const make = Effect.gen(function* () { }); if (environment.platform === "darwin") { + // Mouse drivers can deliver Back/Forward as macOS swipe gestures instead of buttons. + window.on("swipe", (_event, direction) => { + const history = window.webContents.navigationHistory; + if (direction === "left" && history.canGoBack()) history.goBack(); + else if (direction === "right" && history.canGoForward()) history.goForward(); + }); window.on("enter-full-screen", () => { window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); }); diff --git a/apps/marketing/public/pfps/AtharvaIngle7.webp b/apps/marketing/public/pfps/AtharvaIngle7.webp new file mode 100644 index 0000000000000..3352a53161520 Binary files /dev/null and b/apps/marketing/public/pfps/AtharvaIngle7.webp differ diff --git a/apps/marketing/public/pfps/BennettBuhner.webp b/apps/marketing/public/pfps/BennettBuhner.webp deleted file mode 100644 index 39d5af923c51e..0000000000000 Binary files a/apps/marketing/public/pfps/BennettBuhner.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/Josikinz.webp b/apps/marketing/public/pfps/Josikinz.webp deleted file mode 100644 index 68d5825222ac0..0000000000000 Binary files a/apps/marketing/public/pfps/Josikinz.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/MichaelArnaldi.webp b/apps/marketing/public/pfps/MichaelArnaldi.webp new file mode 100644 index 0000000000000..812ceb1c2ff12 Binary files /dev/null and b/apps/marketing/public/pfps/MichaelArnaldi.webp differ diff --git a/apps/marketing/public/pfps/PavolRusnak.webp b/apps/marketing/public/pfps/PavolRusnak.webp new file mode 100644 index 0000000000000..09ed72e80f4a0 Binary files /dev/null and b/apps/marketing/public/pfps/PavolRusnak.webp differ diff --git a/apps/marketing/public/pfps/Rasmic.webp b/apps/marketing/public/pfps/Rasmic.webp new file mode 100644 index 0000000000000..e6bd6bcae1d26 Binary files /dev/null and b/apps/marketing/public/pfps/Rasmic.webp differ diff --git a/apps/marketing/public/pfps/Shay_Benshabtay.webp b/apps/marketing/public/pfps/Shay_Benshabtay.webp deleted file mode 100644 index c827f7504c44e..0000000000000 Binary files a/apps/marketing/public/pfps/Shay_Benshabtay.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/amritwt.webp b/apps/marketing/public/pfps/amritwt.webp new file mode 100644 index 0000000000000..a89bc38f5a3ab Binary files /dev/null and b/apps/marketing/public/pfps/amritwt.webp differ diff --git a/apps/marketing/public/pfps/appfactory.webp b/apps/marketing/public/pfps/appfactory.webp new file mode 100644 index 0000000000000..92ee890b71cb8 Binary files /dev/null and b/apps/marketing/public/pfps/appfactory.webp differ diff --git a/apps/marketing/public/pfps/aronprins.webp b/apps/marketing/public/pfps/aronprins.webp deleted file mode 100644 index 90db37d28f2d4..0000000000000 Binary files a/apps/marketing/public/pfps/aronprins.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/developedbyed.webp b/apps/marketing/public/pfps/developedbyed.webp deleted file mode 100644 index 8f1dfbab6dbca..0000000000000 Binary files a/apps/marketing/public/pfps/developedbyed.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/dhh.webp b/apps/marketing/public/pfps/dhh.webp new file mode 100644 index 0000000000000..1bdca7e527f0c Binary files /dev/null and b/apps/marketing/public/pfps/dhh.webp differ diff --git a/apps/marketing/public/pfps/enunomaduro.webp b/apps/marketing/public/pfps/enunomaduro.webp deleted file mode 100644 index ce054dbfb6558..0000000000000 Binary files a/apps/marketing/public/pfps/enunomaduro.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/iamkaffe.webp b/apps/marketing/public/pfps/iamkaffe.webp deleted file mode 100644 index ded6ed2cd868f..0000000000000 Binary files a/apps/marketing/public/pfps/iamkaffe.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/kostyniuk00.webp b/apps/marketing/public/pfps/kostyniuk00.webp deleted file mode 100644 index c52f0b305b9b2..0000000000000 Binary files a/apps/marketing/public/pfps/kostyniuk00.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/leodev.webp b/apps/marketing/public/pfps/leodev.webp deleted file mode 100644 index 91a47d5de9429..0000000000000 Binary files a/apps/marketing/public/pfps/leodev.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/linuz90.webp b/apps/marketing/public/pfps/linuz90.webp new file mode 100644 index 0000000000000..23bcb27f88c0a Binary files /dev/null and b/apps/marketing/public/pfps/linuz90.webp differ diff --git a/apps/marketing/public/pfps/mil000.webp b/apps/marketing/public/pfps/mil000.webp deleted file mode 100644 index 37d784b752cfb..0000000000000 Binary files a/apps/marketing/public/pfps/mil000.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/mweinbach.webp b/apps/marketing/public/pfps/mweinbach.webp new file mode 100644 index 0000000000000..f7a30f82b1f66 Binary files /dev/null and b/apps/marketing/public/pfps/mweinbach.webp differ diff --git a/apps/marketing/public/pfps/pcstyle53.webp b/apps/marketing/public/pfps/pcstyle53.webp new file mode 100644 index 0000000000000..6d17caae22d8c Binary files /dev/null and b/apps/marketing/public/pfps/pcstyle53.webp differ diff --git a/apps/marketing/public/pfps/pocarles.webp b/apps/marketing/public/pfps/pocarles.webp deleted file mode 100644 index f259df8312482..0000000000000 Binary files a/apps/marketing/public/pfps/pocarles.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/serhiyhvala.webp b/apps/marketing/public/pfps/serhiyhvala.webp new file mode 100644 index 0000000000000..f299338d9cd58 Binary files /dev/null and b/apps/marketing/public/pfps/serhiyhvala.webp differ diff --git a/apps/marketing/public/pfps/szdziedzic.webp b/apps/marketing/public/pfps/szdziedzic.webp new file mode 100644 index 0000000000000..a4b193b12a0cf Binary files /dev/null and b/apps/marketing/public/pfps/szdziedzic.webp differ diff --git a/apps/marketing/public/pfps/teja2495.webp b/apps/marketing/public/pfps/teja2495.webp deleted file mode 100644 index 457901988d4ce..0000000000000 Binary files a/apps/marketing/public/pfps/teja2495.webp and /dev/null differ diff --git a/apps/marketing/src/lib/tweets.ts b/apps/marketing/src/lib/tweets.ts index 604fbec91484b..54fed029a849b 100644 --- a/apps/marketing/src/lib/tweets.ts +++ b/apps/marketing/src/lib/tweets.ts @@ -15,63 +15,64 @@ export const tweets = [ link: "https://x.com/QuinnyPig/status/2089581739887014376", }, { - handle: "tannerlinsley", - content: "I would just use T3 Code at that point and contribute back.", - link: "https://x.com/tannerlinsley/status/2035067912659341399", + handle: "dhh", + content: + "Omarchy is going to ship with an installer for T3 Code because it's a cool project that a lot of people enjoy. Squashing beefs in the process is just bonus.", + excerpt: + "Omarchy is going to ship with an installer for T3 Code because it's a cool project...", + link: "https://x.com/dhh/status/2090332565966123041", }, { - handle: "ryanrhughes", + handle: "MichaelArnaldi", content: - "I think I've finally found the one AI harness to steal me away from the terminal — @t3dotcodes.", - excerpt: "I think I've finally found the one AI harness to steal me away from the terminal", - link: "https://x.com/ryanrhughes/status/2093051703616573769", + "Since I've started using T3 Code I no longer care which harness is used, I'd actually love to get rid of all of them and just use @tim_smart 's clanka", + excerpt: "Since I've started using T3 Code I no longer care which harness is used", + link: "https://x.com/MichaelArnaldi/status/2110290227021365720", + }, + { + handle: "DavidKPiano", + content: "It's like Claude Code if they didn't vibe-code the entire thing", + link: "https://x.com/DavidKPiano/status/2054682983504719930", }, { - handle: "developedbyed", - content: "might say alpha but T3 code is pretty awesome, really fast too", - link: "https://x.com/developedbyed/status/2030627970532921605", + handle: "tannerlinsley", + content: "I would just use T3 Code at that point and contribute back.", + link: "https://x.com/tannerlinsley/status/2035067912659341399", }, { handle: "RhysSullivan", content: "T3 Code is worth giving a try if you haven’t yet\n\nIt takes what I liked so much about codex’s remote setup and improves upon it, it’s a great UX using it on both desktop and mobile\n\nAlso is just nice to have all the official harnesses in one app", - excerpt: - "It takes what I liked so much about codex’s remote setup and improves upon it, it’s a great UX using it on both desktop and mobile", + excerpt: "It takes what I liked so much about codex’s remote setup and improves upon it", link: "https://x.com/RhysSullivan/status/2089750636686643493", }, { - handle: "DavidKPiano", - content: "It's like Claude Code if they didn't vibe-code the entire thing", - link: "https://x.com/DavidKPiano/status/2054682983504719930", - }, - { - handle: "JoshuaRileyDev", + handle: "Rasmic", content: - "Well I was 2,000 miles away on holiday and able to do iOS development from just my phone, T3 Code’s remote feature is a magical experience", - excerpt: "I was 2,000 miles away on holiday and able to do iOS development from just my phone", - link: "https://x.com/JoshuaRileyDev/status/2094379365408616837", + "I can't believe my favourite gui is built by a YouTuber and a Swedish guy named Julius", + link: "https://x.com/Rasmic/status/2113063390515995066", }, { - handle: "aronprins", + handle: "AtharvaIngle7", content: - "T3 Code works better than Claude and Codex combined… and T3 actually combines then (and more!) 🤯\n\nHuge shoutout to @theo and @jullerino\n\nAmazing product fellers 🔥🙌", - excerpt: - "T3 Code works better than Claude and Codex combined… and T3 actually combines then (and more!)", - link: "https://x.com/aronprins/status/2089761738358915267", + "T3 Code is miles ahead of anything else i've tried (even the Codex app, which i haven't opened in a month fyi).", + excerpt: "T3 Code is miles ahead of anything else i've tried", + link: "https://x.com/AtharvaIngle7/status/2114267747203039682", }, { - handle: "teja2495", + handle: "appfactory", content: - "I’ve completely switched to T3 Code for all my workflows. I just switch between different subscriptions and harnesses depending on what I need.", - excerpt: "I’ve completely switched to T3 Code for all my workflows.", - link: "https://x.com/teja2495/status/2052420254991581623", + "This is a popular opinion now, but T3 Code is a pleasure to use.\n\nIt is the most sane version of an agent orchestrator that I've used: It has well thought out defaults and good performance.\n\nWell done T3 Team!", + excerpt: "It is the most sane version of an agent orchestrator that I've used", + link: "https://x.com/appfactory/status/2108498055074812393", }, { - handle: "ryanrhughes", + handle: "linuz90", content: - "Then there's the mobile app. All of my threads, on all of my environments, in my pocket. Fantastic.\n\nWhile traveling the other day, I had to use T3 Code alongside my old Claude Code + Herdr setup from 36,000ft on my phone.\n\nOn T3 Code, it felt like LAN. On the TUI, every keystroke took about 1sec to show up because it had to roundtrip to the device before I was able to actually see it. Brutal.", - excerpt: "...from 36,000ft on my phone. On T3 Code, it felt like LAN.", - link: "https://x.com/ryanrhughes/status/2093051708939059204", + "I’m at the gym and my own personal gym app misses some exercises.\n\nSo I launch a T3 Code thread and ship updates as I train.\n\nGolden age for makers or peak AI psychosis?", + excerpt: + "...gym app misses some exercises. So I launch a T3 Code thread and ship updates as I train.", + link: "https://x.com/linuz90/status/2110051011578745210", }, { handle: "ex0t1clol", @@ -79,42 +80,30 @@ export const tweets = [ link: "https://x.com/ex0t1clol/status/2054666870008021197", }, { - handle: "enunomaduro", - content: "So I tried @theo's T3 Code...", - link: "https://x.com/enunomaduro/status/2091915930141876495", + handle: "mweinbach", + content: "T3 Code with connect in just a web browser is so killer I love it so much", + link: "https://x.com/mweinbach/status/2077595624707395636", }, { - handle: "pocarles", - content: - "Only using Codex and T3 now.\n\nI thought all AI coding harnesses had roughly the same impact. Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", - excerpt: - "Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", - link: "https://x.com/pocarles/status/2054673964274758046", - }, - { - handle: "BennettBuhner", + handle: "ryanrhughes", content: - "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere! The app is great but the website is even greater, so instead of needing to SSH into a machine, T3 IS my SSH!", - excerpt: - "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere.", - link: "https://x.com/BennettBuhner/status/2054667115697754387", + "I think I've finally found the one AI harness to steal me away from the terminal — @t3dotcodes.", + excerpt: "I think I've finally found the one AI harness to steal me away from the terminal", + link: "https://x.com/ryanrhughes/status/2093051703616573769", }, { - handle: "leodev", + handle: "PavolRusnak", content: - "T3 Code is probably the first coding gui that didn't suck performance wise and had a smart team behind it.", - link: "https://x.com/leodev/status/2054679746353537042", + "Super pleasantly surprised with @t3dotcodes\n\nSpent countless hours trying to get OpenClaw or Hermes Agent working for my use cases to no avail\n\nBut with t3 everything just works as expected and on my first try!", + excerpt: "...with t3 everything just works as expected and on my first try!", + link: "https://x.com/PavolRusnak/status/2114844388136267873", }, { - handle: "Shay_Benshabtay", + handle: "amritwt", content: - "T3 code is not perfect, but it's damn good. Open and fun , I can make my own fork for my uses and use the great tooling with my own needs.", - link: "https://x.com/Shay_Benshabtay/status/2054668503857156326", - }, - { - handle: "iamkaffe", - content: "T3Code was the first one to truly care about Linux users.", - link: "https://x.com/iamkaffe/status/2054675539311411280", + "I finally installed T3 Code and I am in awe about how it connects to a remote VM seamlessly with no Tailscale", + excerpt: "...I am in awe about how it connects to a remote VM seamlessly with no Tailscale", + link: "https://x.com/amritwt/status/2104046763418263999", }, { handle: "_winter_wonders", @@ -122,22 +111,32 @@ export const tweets = [ link: "https://x.com/_winter_wonders/status/2052350198764970434", }, { - handle: "kostyniuk00", + handle: "szdziedzic", content: - "I was not expecting a year ago, that my anti-AI colleagues would thank me a year later, for persuading them to try T3 Code Beta, that really helped them with organizing their workflows. \n\nFantastic product by @jullerino and @theo. Go try it out if you haven’t yet!", - excerpt: - "My anti-AI colleagues thanked me for persuading them to try T3 Code Beta. Fantastic product.", - link: "https://x.com/kostyniuk00/status/2052041388179468521", + "I became a T3 Code user recently - it’s great especially if you have many subs and many machines to manage. I recommend giving it a shot if you haven’t already 👏.", + excerpt: "...it’s great especially if you have many subs and many machines to manage.", + link: "https://x.com/szdziedzic/status/2103225721007005823", + }, + { + handle: "JoshuaRileyDev", + content: + "Well I was 2,000 miles away on holiday and able to do iOS development from just my phone, T3 Code’s remote feature is a magical experience", + excerpt: "I was 2,000 miles away on holiday and able to do iOS development from just my phone", + link: "https://x.com/JoshuaRileyDev/status/2094379365408616837", }, { - handle: "Josikinz", + handle: "pcstyle53", content: - "T3 code is better because it’s like if the codex Mac app didn’t make my computer run like shit 😋", - link: "https://x.com/Josikinz/status/2030367951694745870", + "the fact that @theo's team got the terminal in t3 code so good that you can use advanced TUIs inside it is insane to me", + excerpt: + "...got the terminal in t3 code so good that you can use advanced TUIs inside it is insane to me", + link: "https://x.com/pcstyle53/status/2080949310401757306", }, { - handle: "mil000", - content: "T3 code saved my relationship!", - link: "https://x.com/mil000/status/2030120041451246071", + handle: "serhiyhvala", + content: + "Now it’s the best app I’ve ever used. I can’t imagine my workday without it. I even cancelled my Cursor subscription. I just don’t need it anymore.\n\nIf you tried it once and bounced, give it a second chance.", + excerpt: "If you tried it once and bounced, give it a second chance.", + link: "https://x.com/serhiyhvala/status/2115375016061608018", }, ] satisfies Tweet[]; diff --git a/apps/mobile/app.config.test.ts b/apps/mobile/app.config.test.ts new file mode 100644 index 0000000000000..b8111f45587cf --- /dev/null +++ b/apps/mobile/app.config.test.ts @@ -0,0 +1,33 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; + +// Keep config parsing independent of local env files and build credentials. +vi.mock("../../scripts/lib/public-config.ts", () => ({ + loadRepoEnv: () => ({ APP_VARIANT: process.env.APP_VARIANT }), +})); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +async function loadConfig(variant: string | undefined) { + vi.resetModules(); + vi.stubEnv("APP_VARIANT", variant); + return (await import("./app.config.ts")).default; +} + +it.each(["prod", ""])("rejects APP_VARIANT=%j before selecting production IDs", async (value) => { + await expect(loadConfig(value)).rejects.toThrow( + "APP_VARIANT must be development, preview, or production.", + ); +}); + +it.each([ + [undefined, "com.t3tools.t3code"], + ["development", "com.t3tools.t3code.dev"], + ["preview", "com.t3tools.t3code.preview"], + ["production", "com.t3tools.t3code"], +])("keeps the iOS and Android identifiers for APP_VARIANT=%j", async (value, identifier) => { + const config = await loadConfig(value); + expect(config.ios?.bundleIdentifier).toBe(identifier); + expect(config.android?.package).toBe(identifier); +}); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ea885f819984f..e6d388b9a9856 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -108,8 +108,10 @@ function resolveAppVariant(value: string | undefined): AppVariant { case "preview": case "production": return value; - default: + case undefined: return "production"; + default: + throw new Error("APP_VARIANT must be development, preview, or production."); } } diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index 22fcb74a36cfb..5ff3009ef4d20 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -4,7 +4,8 @@ "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"], "appDelegateSubscribers": [ "T3WorkspaceOrientationSubscriber", - "T3MenuDismissTouchGateSubscriber" + "T3MenuDismissTouchGateSubscriber", + "T3BackgroundGraceSubscriber" ] }, "android": { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3BackgroundGraceSubscriber.swift b/apps/mobile/modules/t3-native-controls/ios/T3BackgroundGraceSubscriber.swift new file mode 100644 index 0000000000000..62eb5d86ad12e --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3BackgroundGraceSubscriber.swift @@ -0,0 +1,23 @@ +import ExpoModulesCore +import UIKit + +public final class T3BackgroundGraceSubscriber: ExpoAppDelegateSubscriber { + private var task = UIBackgroundTaskIdentifier.invalid + + public func applicationWillResignActive(_ application: UIApplication) { + guard task == .invalid else { return } + task = application.beginBackgroundTask(withName: "T3 connection grace") { [weak self] in + self?.endTask(application) + } + } + + public func applicationDidBecomeActive(_ application: UIApplication) { + endTask(application) + } + + private func endTask(_ application: UIApplication) { + guard task != .invalid else { return } + application.endBackgroundTask(task) + task = .invalid + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 8cb7ad17e0d0b..801f1c1ee64e6 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -483,6 +483,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont ) postInvalidateOnAnimation() } else { + verticalScroller.forceFinished(true) canvasView.setVerticalOffset(target) } } diff --git a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt index 10d9a45107c73..4a39467cbdc26 100644 --- a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt +++ b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt @@ -22,6 +22,30 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [35], manifest = Config.NONE) class ReviewDiffViewportTest { + @Test + fun immediateNavigationStopsAnActiveFling() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val view = T3ReviewDiffView(activity, AppContext()) + val scroller = view.javaClass.getDeclaredField("verticalScroller").apply { + isAccessible = true + }.get(view) as android.widget.OverScroller + try { + scroller.fling(0, 100, 0, 2000, 0, 0, 0, 10000) + assertTrue(!scroller.isFinished) + view.scrollToTop(false) + view.computeScroll() + val canvas = view.getChildAt(0) + val offset = canvas.javaClass.getDeclaredMethod("verticalOffset").apply { + isAccessible = true + }.invoke(canvas) + assertEquals(0, offset) + assertTrue(scroller.isFinished) + } finally { + view.cleanup() + activity.finish() + } + } + @Test fun resetDiscardsOldRowsAndPendingDecodeBeforePublishingTheNewDestination() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() diff --git a/apps/mobile/modules/t3-source-text/src/util.test.ts b/apps/mobile/modules/t3-source-text/src/util.test.ts new file mode 100644 index 0000000000000..c3af9a1f8ff85 --- /dev/null +++ b/apps/mobile/modules/t3-source-text/src/util.test.ts @@ -0,0 +1,15 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { flattenStyles } from "./util"; + +vi.mock("react-native", () => ({ + I18nManager: { isRTL: false }, + StyleSheet: { + flatten: (styles: ReadonlyArray) => Object.assign({}, ...styles), + }, +})); + +it("encodes numeric bold weights as bold source-text runs", () => { + expect(flattenStyles({ fontWeight: 700 }, undefined).fontWeight).toBe("bold"); + expect(flattenStyles({ fontWeight: "400" }, { fontWeight: "700" }).fontWeight).toBe("bold"); +}); diff --git a/apps/mobile/modules/t3-source-text/src/util.ts b/apps/mobile/modules/t3-source-text/src/util.ts index 461fc7684b4b6..47f219bd3aad6 100644 --- a/apps/mobile/modules/t3-source-text/src/util.ts +++ b/apps/mobile/modules/t3-source-text/src/util.ts @@ -64,7 +64,7 @@ function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFont return "semibold"; case 700: case "700": - return "semibold"; + return "bold"; case 800: case "800": return "bold"; diff --git a/apps/mobile/modules/t3-terminal/android/build.gradle b/apps/mobile/modules/t3-terminal/android/build.gradle index ba79f390bd655..b7d24702b0a38 100644 --- a/apps/mobile/modules/t3-terminal/android/build.gradle +++ b/apps/mobile/modules/t3-terminal/android/build.gradle @@ -9,6 +9,10 @@ android { ndkVersion rootProject.ext.ndkVersion compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -37,4 +41,12 @@ android { dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 1deb3a6170350..5646ab8c5884b 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -271,21 +271,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS inputView.setPadding(0, 0, 0, 0) - inputView.setOnEditorActionListener { _, actionId, event -> - if (readOnly) return@setOnEditorActionListener true - val isKeyUp = event?.action == KeyEvent.ACTION_UP - val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp - val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER && - event.action == KeyEvent.ACTION_DOWN - val isEnter = isImeSend || isHardwareEnter - if (isEnter) { + inputView.setOnEditorActionListener( + TerminalEnterListener({ readOnly }) { // Enter must send CR: raw-mode TUIs treat LF as Ctrl+J (insert newline). onInput(mapOf("data" to "\r")) - true - } else { - false - } - } + }, + ) inputView.setOnKeyListener { _, keyCode, event -> if (readOnly) return@setOnKeyListener true if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalEnterListener.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalEnterListener.kt new file mode 100644 index 0000000000000..6eec4ba97ece2 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalEnterListener.kt @@ -0,0 +1,22 @@ +package expo.modules.t3terminal + +import android.view.KeyEvent +import android.view.inputmethod.EditorInfo +import android.widget.TextView + +internal class TerminalEnterListener( + private val isReadOnly: () -> Boolean, + private val sendEnter: () -> Unit +) : TextView.OnEditorActionListener { + override fun onEditorAction(view: TextView?, actionId: Int, event: KeyEvent?): Boolean { + if (isReadOnly()) return true + val isKeyUp = event?.action == KeyEvent.ACTION_UP + val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp + val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER || + event?.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER + if (isImeSend || (isHardwareEnter && !isKeyUp)) sendEnter() + // An unconsumed Enter key-up makes a single-line TextView move focus to + // the next view, so the terminal stops receiving keys. + return isImeSend || isHardwareEnter + } +} diff --git a/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalEnterListenerTest.kt b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalEnterListenerTest.kt new file mode 100644 index 0000000000000..de04c7adfe4c0 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalEnterListenerTest.kt @@ -0,0 +1,51 @@ +package expo.modules.t3terminal + +import android.app.Activity +import android.view.KeyEvent +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class TerminalEnterListenerTest { + @Test + fun hardwareEnterKeepsFocusOnTheTerminalInput() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + var sent = 0 + val input = EditText(activity).apply { + setSingleLine(true) + isFocusableInTouchMode = true + setOnEditorActionListener(TerminalEnterListener({ false }) { sent += 1 }) + } + val nextControl = Button(activity).apply { isFocusableInTouchMode = true } + val root = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + addView(input) + addView(nextControl) + } + activity.setContentView(root) + // Focus search below the input only finds controls that have bounds. + root.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, 1080, 1920) + assertTrue(input.requestFocus()) + + for (keyCode in listOf(KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER)) { + input.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + input.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) + assertTrue("focus left the input after key $keyCode", input.isFocused) + } + assertEquals(2, sent) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f5c1c6521812e..a49c3856864f1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -176,7 +176,8 @@ "@react-native-menu/menu", "expo-audio", "expo-video", - "expo-network" + "expo-network", + "expo-sharing" ] } }, diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7a0042903a3c3..872ca142c8aaa 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1006,6 +1006,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, + sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, }, }), NotFound: createNativeStackScreen({ diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index 37a7d0e236f19..ea901f2441331 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -1,6 +1,7 @@ import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useIsFocused } from "@react-navigation/native"; +import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; @@ -8,6 +9,8 @@ import { AppText } from "./ui/AppText"; import { SymbolView } from "./ui/AppSymbol"; import { VideoThumbnailImage } from "./VideoThumbnailImage"; import { useMediaActions } from "../state/mediaActions"; +import { fitVideoFrame } from "../lib/videoFrameFit"; +import { loadVideoThumbnail } from "../lib/videoThumbnails"; import { type MediaActionsSource } from "../lib/mediaActionsSource"; import { useMediaVideoPlayback } from "../lib/useMediaVideoPlayback"; import { MediaActionsMenu } from "./MediaActionsMenu"; @@ -136,6 +139,8 @@ interface MediaVideoPlayerProps { readonly visible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; + /** Fill the parent and size the frame to the video's shape instead of a fixed 16:9 slot. */ + readonly fitToVideo?: boolean; readonly autoPlay?: boolean; readonly paused?: boolean; readonly onPlaybackStart?: () => void; @@ -145,16 +150,41 @@ interface MediaVideoPlayerProps { function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { const mediaActions = useMediaActions(props.actionsSource); + const isFocused = useIsFocused(); const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); + const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null); + const sizeSource = props.fitToVideo && isFocused && videoSize === null ? props.uri : null; + // Queue before the poster so early Play cannot cancel the extraction that supplies frame dimensions. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads native thumbnail dimensions independently of the poster's lifetime + useLayoutEffect(() => { + if (sizeSource === null) return; + // Thumbnails are oriented like the displayed frame, unlike VideoTrack.size. + const controller = new AbortController(); + void loadVideoThumbnail( + props.thumbnailKey, + async () => ({ uri: sizeSource, dispose: () => undefined }), + controller.signal, + ).then((thumbnail) => { + if (thumbnail && !controller.signal.aborted) { + setVideoSize({ width: thumbnail.width, height: thumbnail.height }); + } + }); + return () => controller.abort(); + }, [props.thumbnailKey, sizeSource]); + const fittedFrame = + props.fitToVideo && videoSize && bounds ? fitVideoFrame(videoSize, bounds, 480) : null; - return ( + const frame = ( @@ -214,6 +244,18 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { )} ); + if (!props.fitToVideo) return frame; + return ( + { + const { width, height } = event.nativeEvent.layout; + setBounds({ width, height }); + }} + > + {frame} + + ); } export function MediaVideoPlayer(props: MediaVideoPlayerProps) { diff --git a/apps/mobile/src/components/RelativeTime.tsx b/apps/mobile/src/components/RelativeTime.tsx new file mode 100644 index 0000000000000..8d2067a4907f9 --- /dev/null +++ b/apps/mobile/src/components/RelativeTime.tsx @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from "react"; +import { msUntilRelativeTimeChange, relativeTime } from "../lib/time"; + +// The React Compiler caches inline relativeTime calls on the timestamp alone. +export function RelativeTime(props: { timestamp: string }) { + const subscribe = (onChange: () => void) => { + let timer: ReturnType | undefined; + const schedule = () => { + const delayMs = msUntilRelativeTimeChange(props.timestamp); + if (delayMs === null) return; + timer = setTimeout(() => { + onChange(); + schedule(); + }, delayMs); + }; + schedule(); + return () => clearTimeout(timer); + }; + const getSnapshot = () => relativeTime(props.timestamp); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/mobile/src/components/ScrollComposeButton.android.tsx b/apps/mobile/src/components/ScrollComposeButton.android.tsx index e9e57373e19c0..d107d9be5d8a0 100644 --- a/apps/mobile/src/components/ScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/ScrollComposeButton.android.tsx @@ -3,13 +3,12 @@ import { Box, ExtendedFloatingActionButton, Icon, Text } from "@expo/ui/jetpack- import { defaultMinSize, fillMaxWidth, - graphicsLayer, height, onSizeChanged, size, } from "@expo/ui/jetpack-compose/modifiers"; import { useState } from "react"; -import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; +import { I18nManager, Pressable, View, type StyleProp, type ViewStyle } from "react-native"; import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { resolveScaledTextRole } from "../lib/appearancePreferences"; @@ -37,7 +36,14 @@ export function ScrollComposeButton(props: { className={props.className} style={[props.style, { left: 20, height: fabSize }]} > - + diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts index 92512bd2c3e87..47aed622569f6 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -105,6 +105,56 @@ describe("extractAgentNotificationDeepLink", () => { }); describe("routeAgentNotificationResponseOnce", () => { + it("retries a failed initial response and deduplicates it after navigation succeeds", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({ deepLink: "/threads/env/thread" }) as NotificationResponse; + const handledResponseIds = new Set(); + const navigate = vi.fn().mockImplementationOnce(() => { + throw new Error("navigation not ready"); + }); + const clearLastResponse = vi.fn(() => Promise.resolve()); + const consume = () => + consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse, + handleResponse: (response) => + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }), + }); + + await consume(); + expect(handledResponseIds.size).toBe(0); + expect(clearLastResponse).not.toHaveBeenCalled(); + + await consume(); + expect(navigate).toHaveBeenCalledTimes(2); + expect(navigate).toHaveBeenLastCalledWith("/threads/env/thread"); + expect(clearLastResponse).toHaveBeenCalledOnce(); + + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }); + expect(navigate).toHaveBeenCalledTimes(2); + }); + + it("discards malformed destination ids without leaving the response pending", async () => { + const response = responseWithData({ + environmentId: "env", + threadId: "\ud800", + }) as NotificationResponse; + const handledResponseIds = new Set(); + const navigate = vi.fn(); + const clearLastResponse = vi.fn(() => Promise.resolve()); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse, + handleResponse: (response) => + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }), + }); + + expect(navigate).not.toHaveBeenCalled(); + expect(clearLastResponse).toHaveBeenCalledOnce(); + expect(handledResponseIds.has("notification-1")).toBe(true); + }); + it("does not navigate twice when the initial and listener responses refer to one notification", () => { const handledResponseIds = new Set(); const navigations: Array = []; diff --git a/apps/mobile/src/features/agent-awareness/notificationPayload.ts b/apps/mobile/src/features/agent-awareness/notificationPayload.ts index a9dfe80369374..a86d1c5c14ba4 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPayload.ts @@ -41,7 +41,11 @@ function encodeThreadDeepLink(input: { if (input.environmentId.length === 0 || input.threadId.length === 0) { return null; } - return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; + try { + return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; + } catch { + return null; + } } function normalizeThreadDeepLink(value: string): string | null { @@ -104,11 +108,11 @@ export function routeAgentNotificationResponseOnce(input: { if (responseId && input.handledResponseIds.has(responseId)) { return; } - if (responseId) { - input.handledResponseIds.add(responseId); - } const deepLink = extractAgentNotificationDeepLink(input.response); if (deepLink) { input.navigate(deepLink); } + if (responseId) { + input.handledResponseIds.add(responseId); + } } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 62e43c04cda61..e958dbfdaaa18 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -24,7 +24,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { relativeTime } from "../../lib/time"; +import { RelativeTime } from "../../components/RelativeTime"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useServerConfigs } from "../../state/entities"; import { useEnvironmentScope } from "../../state/session"; @@ -193,7 +193,6 @@ function ArchivedThreadRow(props: { props.thread.environmentId, AuthOrchestrationOperateScope, ); - const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); @@ -219,7 +218,7 @@ function ArchivedThreadRow(props: { {props.thread.title} - {timestamp} + {subtitle.length > 0 ? ( diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx index 842d58c9aa371..03b6daaa4e715 100644 --- a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -14,6 +14,7 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import * as Dialog from "../../components/ui/Dialog"; import { Menu } from "../../components/ui/Menu"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; import { deregisterManagedRelayEnvironmentCommand, @@ -46,6 +47,9 @@ export function T3ConnectProfilePage() { const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { reportFailure: false, }); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refreshAfterMutation, { + reportFailure: false, + }); const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = useState(null); const mutationPendingRef = useRef(false); @@ -75,6 +79,7 @@ export function T3ConnectProfilePage() { return { accountId, linkedAtById }; }); environmentsState.refresh(); + void refreshRelayEnvironments(); return; } if (isAtomCommandInterrupted(result)) return; diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 1133e17113ee4..c7866cea6ce06 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -283,6 +283,9 @@ export function ConnectionEnvironmentRow(props: { )} props.onReconnect(props.environment.environmentId)} @@ -296,6 +299,8 @@ export function ConnectionEnvironmentRow(props: { props.onRemove(props.environment.environmentId)} > diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 8e7ee048587f0..117dbc44ede58 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -361,6 +361,7 @@ function useThreadFilesWorkspace(params: { readonly threadId?: string | string[]; readonly cwd?: string | string[]; readonly projectName?: string | string[]; + readonly path?: string | string[]; }) { const routeEnvironmentId = firstRouteParam(params.environmentId); const routeThreadId = firstRouteParam(params.threadId); @@ -382,6 +383,7 @@ function useThreadFilesWorkspace(params: { cwd: routeCwd ?? selectedThreadCwd ?? project?.workspaceRoot ?? null, environmentId, projectName: routeProjectName ?? project?.title ?? "Files", + relativePath: normalizeRoutePath(params.path), selectedThread, threadId, }; @@ -592,11 +594,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const params = props.route.params; - const relativePath = normalizeRoutePath(params.path); const targetLine = normalizeRouteLine(firstRouteParam(params.line)); - const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( - props.route.params, - ); + const { cwd, environmentId, projectName, relativePath, selectedThread, threadId } = + useThreadFilesWorkspace(props.route.params); const fileAccessSession = useEnvironmentQuery( environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, ); diff --git a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx index 72b31f061e964..7ee4a818954d5 100644 --- a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx @@ -19,6 +19,7 @@ export function WorkspaceFileVideoPreview(props: { resolvePlaybackUri={props.resolvePlaybackUri} name={props.name} thumbnailKey={props.thumbnailKey} + fitToVideo actionsSource={props.source?.actionsSource} /> diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index fe1401326ab56..c522da701e75c 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -10,7 +10,10 @@ import { useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; +import { + pickableProjectGroups, + resolveScratchProjectScopeKey, +} from "@t3tools/client-runtime/state/project-grouping"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects } from "../../state/entities"; @@ -138,11 +141,13 @@ export function HomeRouteScreen() { ); const projectFilterOptions = useMemo( () => - buildHomeProjectScopes({ - projects, - environmentId: selectedEnvironmentId, - projectGroupingMode: listOptions.projectGroupingMode, - }).map((scope) => ({ + pickableProjectGroups( + buildHomeProjectScopes({ + projects, + environmentId: selectedEnvironmentId, + projectGroupingMode: listOptions.projectGroupingMode, + }), + ).map((scope) => ({ key: scope.key, label: scope.title, })), diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 1edb92f0d556b..243e693d1b893 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -9,6 +9,7 @@ import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -53,7 +54,7 @@ import { useThreadListV2 } from "../threads/useThreadListV2"; import { useThreadListNow } from "../threads/use-thread-list-now"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; -import { threadListEnvironmentsAtom } from "../../state/server"; +import { threadListEnvironmentsAtom } from "../../state/presentation"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { @@ -376,7 +377,11 @@ export function HomeScreen(props: HomeScreenProps) { })(); const v2ProjectScopeKey = props.selectedProjectKey; - const v2ScopedProjectGroup = findHomeProjectScope(projectScopes, v2ProjectScopeKey); + // An archived project's filter reads as all projects, matching the filter menu. + const v2ScopedProjectGroup = findHomeProjectScope( + pickableProjectGroups(projectScopes), + v2ProjectScopeKey, + ); const threadSearch = useThreadSearch( searchEnvironmentIds, props.searchQuery, @@ -711,7 +716,10 @@ export function HomeScreen(props: HomeScreenProps) { ({ key: scope.key, label: scope.title }))} + projects={pickableProjectGroups(projectScopes).map((scope) => ({ + key: scope.key, + label: scope.title, + }))} selectedEnvironmentId={props.selectedEnvironmentId} selectedProjectKey={props.selectedProjectKey} onEnvironmentChange={props.onEnvironmentChange} diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 82eab6b384b9c..1b2e1a8c541f7 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -27,6 +27,8 @@ export interface HomeProjectScope { readonly representative: EnvironmentProject; readonly projects: ReadonlyArray; readonly projectRefs: ReadonlyArray; + readonly pinned: boolean; + readonly archived: boolean; } function getProjectSortTimestamp( @@ -62,6 +64,8 @@ export function buildHomeProjectScopes(input: { representative: group.representative, projects: group.members.map((member) => member.project), projectRefs: group.memberProjectRefs, + pinned: group.pinned, + archived: group.archived, }; }); } diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index ec0f6a14558dd..9313aef299881 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -13,6 +13,7 @@ import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ + connectedEnvironmentIds: [] as EnvironmentId[], pendingOrder: null as object | null, dropBusy: false, failedAction: null as string | null, @@ -60,6 +61,9 @@ vi.mock("../../state/session", () => ({ readEnvironmentScope: (environmentId: string, scope: string) => state.scopes.get(environmentId)?.has(scope) === true, })); +vi.mock("../../state/presentation", () => ({ + connectedEnvironmentIdsAtom: "connected-environments", +})); vi.mock("../../state/server", () => ({ environmentServerConfigsAtom: "server-configs", })); @@ -69,28 +73,30 @@ vi.mock("../../state/atom-registry", () => ({ state.dropBusy = value; }, get: (atom: string) => - atom === "thread-drop-busy" - ? state.dropBusy - : atom === "thread-shells" - ? state.shells - : atom === "queued-thread-keys" - ? new Set() - : new Map( - [...state.scopes.keys()].map((environmentId) => [ - environmentId, - { - environment: { - capabilities: { - threadSettlement: true, - threadSnooze: true, - threadPinning: true, - threadPinReorder: true, - threadTitleRegeneration: true, + atom === "connected-environments" + ? state.connectedEnvironmentIds + : atom === "thread-drop-busy" + ? state.dropBusy + : atom === "thread-shells" + ? state.shells + : atom === "queued-thread-keys" + ? new Set() + : new Map( + [...state.scopes.keys()].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { + threadSettlement: true, + threadSnooze: true, + threadPinning: true, + threadPinReorder: true, + threadTitleRegeneration: true, + }, }, }, - }, - ]), - ), + ]), + ), }, })); vi.mock("../../state/use-atom-command", () => ({ @@ -130,6 +136,7 @@ vi.mock("../../state/threads", () => ({ "pin", "unpin", "reorderPin", + "reorderActive", "updateMetadata", ].map((action) => [ action, @@ -179,6 +186,7 @@ const mutationCases = [ ] as const; beforeEach(() => { + state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; state.failedAction = null; state.archivedRefreshes = []; state.pendingOrder = null; @@ -286,6 +294,76 @@ describe("pinned thread operation permissions", () => { }); }); +describe("thread reorder connection availability", () => { + it.each(["active", "pinned"] as const)( + "rejects %s materialization involving a cached capable offline neighbor", + async (section) => { + const moved = makeThread({ + pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null, + }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("offline-neighbor"), + environmentId: otherEnvironmentId, + createdAt: "2026-09-02T00:00:00.000Z", + pinnedAt: moved.pinnedAt, + }), + ]; + state.scopes.get(otherEnvironmentId)?.add(AuthOrchestrationOperateScope); + state.connectedEnvironmentIds = [primaryEnvironmentId]; + + const { moveThread } = useThreadListActions(); + expect(await moveThread(moved, "up")).toBe(false); + expect(state.requests).toEqual([]); + + state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; + + expect(await moveThread(moved, "up")).toBe(true); + expect(state.requests).toHaveLength(2); + expect(new Set(state.requests.map(({ environmentId }) => environmentId))).toEqual( + new Set([primaryEnvironmentId, otherEnvironmentId]), + ); + }, + ); + + it.each(["active", "pinned"] as const)( + "uses the latest connection state in a retained %s move callback", + async (section) => { + const moved = makeThread({ + pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null, + pinOrderKey: section === "pinned" ? "m" : null, + activeOrderKey: section === "active" ? "m" : null, + }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("offline-neighbor"), + environmentId: otherEnvironmentId, + pinnedAt: moved.pinnedAt, + pinOrderKey: section === "pinned" ? "g" : null, + activeOrderKey: section === "active" ? "g" : null, + }), + ]; + const { moveThread } = useThreadListActions(); + state.connectedEnvironmentIds = []; + + expect(await moveThread(moved, "up")).toBe(false); + expect(state.requests).toEqual([]); + + state.connectedEnvironmentIds = [primaryEnvironmentId]; + + expect(await moveThread(moved, "up")).toBe(true); + expect(state.requests).toEqual([ + expect.objectContaining({ + action: section === "pinned" ? "reorderPin" : "reorderActive", + environmentId: primaryEnvironmentId, + }), + ]); + }, + ); +}); + describe("thread action undo", () => { it.each([ ["settleThread", "settle", "Settled", "unsettle"], diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index cac37f0552802..248c436905311 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -15,6 +15,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; +import { connectedEnvironmentIdsAtom } from "../../state/presentation"; import { readEnvironmentScope } from "../../state/session"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; @@ -591,6 +592,10 @@ export function useThreadListActions(): { return settled; } const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const connectedEnvironmentIds = new Set(appAtomRegistry.get(connectedEnvironmentIdsAtom)); + + if (!connectedEnvironmentIds.has(thread.environmentId)) return false; + if (!configs.has(thread.environmentId)) { Dialog.alert( "Could not move thread", @@ -610,7 +615,9 @@ export function useThreadListActions(): { allThreads: shells, ordered, section, - reorderableEnvironmentIds: new Set(configs.keys()), + reorderableEnvironmentIds: new Set( + [...configs.keys()].filter((environmentId) => connectedEnvironmentIds.has(environmentId)), + ), })(scopedThreadKey(thread.environmentId, thread.id), direction); if (assignments === null) return false; const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 88987819ddf00..841eaae7c6dc2 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -8,7 +8,7 @@ import { type EnvironmentThreadSearchMatch, type ShownOrder, } from "@t3tools/client-runtime/state/thread-search"; -import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import { isListedThreadLineage, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { foldSearchQuery, PALETTE_MESSAGE_LIMIT, @@ -401,7 +401,11 @@ export function CommandPalette(props: { })), ); } - const projectItems: CommandPaletteItem[] = projects.map((project) => ({ + const openProjects = projects.filter((project) => project.archivedAt == null); + const projectItems: CommandPaletteItem[] = [ + ...openProjects.filter((project) => project.pinnedAt != null), + ...openProjects.filter((project) => project.pinnedAt == null), + ].map((project) => ({ key: `project:${scopedProjectKey(project.environmentId, project.id)}`, kind: "project", project, @@ -422,10 +426,7 @@ export function CommandPalette(props: { }), })); const threadItems: CommandPaletteItem[] = threads - .filter( - (thread) => - thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent", - ) + .filter((thread) => thread.archivedAt === null && isListedThreadLineage(thread.lineage)) .map((thread) => { const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); const environment = diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c3b46cf8e24c2..517d3ddf2981b 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -15,7 +15,16 @@ import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3too import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; -import { use, useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + use, + useDeferredValue, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { @@ -129,12 +138,48 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi ); })(); - // useFocusEffect re-runs when the renderer changes while focused, so the - // pane always shows this render's content, and its cleanup deactivates on - // blur and unmount. - useFocusEffect(() => - wrappedRender === undefined ? undefined : registerWorkspaceInspector(wrappedRender), - ); + const focusedRef = useRef(false); + const deactivateRef = useRef<(() => void) | null>(null); + + const syncRegistration = useEffectEvent(() => { + if (!focusedRef.current || wrappedRender === undefined) { + deactivateRef.current?.(); + return; + } + deactivateRef.current = registerWorkspaceInspector(wrappedRender); + }); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Navigation focus events own the inspector registration lifetime. + useEffect(() => { + if (navigation === undefined) return; + const focus = () => { + if (focusedRef.current) return; + focusedRef.current = true; + syncRegistration(); + }; + const blur = () => { + focusedRef.current = false; + syncRegistration(); + }; + if (navigation.isFocused()) focus(); + const unsubscribeFocus = navigation.addListener("focus", focus); + const unsubscribeBlur = navigation.addListener("blur", blur); + return () => { + unsubscribeFocus(); + unsubscribeBlur(); + focusedRef.current = false; + deactivateRef.current?.(); + deactivateRef.current = null; + }; + }, [navigation]); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Changed pane content replaces the focused inspector registration. + useEffect(() => { + if (focusedRef.current) { + syncRegistration(); + } + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed pane content replaces its existing native workspace registration. + }, [wrappedRender]); } export function AdaptiveWorkspaceLayout(props: { diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 218d01041561d..98f6a909899a9 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -713,6 +713,9 @@ function useCreateProject(environment: EnvironmentOption | null) { const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const updateProject = useAtomCommand(projectEnvironment.update, { + label: "project unarchive", + }); const projects = useProjects(); return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { @@ -734,7 +737,19 @@ function useCreateProject(environment: EnvironmentOption | null) { path: workspaceRoot, }); if (existing) { - Dialog.alert("Project already exists", existing.title); + // Adding an archived project's folder again brings the project back. + const unarchived = + existing.archivedAt == null || + ( + await updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }) + )._tag === "Success"; + Dialog.alert( + "Project already exists", + unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, + ); openNewTaskDraft( { environmentId: existing.environmentId, diff --git a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx index f8f7a5f9c5b15..6bdb399d0ad30 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx @@ -9,7 +9,7 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { Menu } from "../../components/ui/Menu"; import type { MenuProps } from "../../components/ui/Menu.types"; import { cn } from "../../lib/cn"; -import { relativeTime } from "../../lib/time"; +import { RelativeTime } from "../../components/RelativeTime"; import { selectedThreadRowColors, THREAD_LIST_V2_SIDEBAR_ROW_STYLE, @@ -132,7 +132,7 @@ export const PullRequestListRow = memo( ) : null} - {relativeTime(item.updatedAt)} + ReadonlyArray) | null>( null, ); +const LineBreaksContext = createContext(false); export function PullRequestMarkdownRepository(props: { /** The change request the markdown belongs to, whose host decides what its text links. */ @@ -44,6 +48,7 @@ export function PullRequestMarkdownRepository(props: { provider === undefined || url === undefined ? null : changeRequestReferenceAutolinker({ provider, url }); + const lineBreaks = sourceControlClients.find(provider ?? "")?.hardLineBreaks ?? false; const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, @@ -53,7 +58,9 @@ export function PullRequestMarkdownRepository(props: { const media = cwd ? { environmentId: target.environmentId, cwd } : null; return ( - {props.children} + + {props.children} + ); } @@ -88,6 +95,7 @@ function MarkdownImage(props: { export function PullRequestMarkdown(props: { readonly markdown: string; readonly imageGroupMarkdown?: string; + readonly lineBreaks?: boolean; }) { const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]); return ( @@ -98,6 +106,7 @@ export function PullRequestMarkdown(props: { key={segment.id} markdown={segment.text} imageGroupMarkdown={props.imageGroupMarkdown ?? props.markdown} + lineBreaks={props.lineBreaks} /> ) : ( @@ -110,7 +119,9 @@ export function PullRequestMarkdown(props: { function PullRequestMarkdownContent(props: { readonly markdown: string; readonly imageGroupMarkdown?: string; + readonly lineBreaks: boolean | undefined; }) { + const hostLineBreaks = useContext(LineBreaksContext); const autolinks = useContext(AutolinksContext); const mediaContext = useContext(PullRequestMediaContext); const markdown = linkReferenceAutolinks(props.markdown, autolinks); @@ -136,6 +147,7 @@ function PullRequestMarkdownContent(props: { - Updated {relativeTime(pr.updatedAt)} ago + Updated ago @@ -873,19 +873,25 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps - + {/* UIKit draws the tapped control's glass lens outside the hidden + tab's opacity, so only the visible tab mounts the control. */} + {Platform.OS !== "ios" || section === tab ? ( + + ) : ( + + )} ) : null} diff --git a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx index b2f51a9bd0abb..cbb751c74c4c7 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx @@ -4,6 +4,7 @@ import { Platform, View } from "react-native"; import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import { AppText as Text } from "../../components/ui/AppText"; import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; +import { pullRequestLayerParams } from "./pullRequestStack"; import { pullRequestStatus } from "./pullRequestStatus"; import type { PullRequestParams } from "./usePullRequestDiff"; import type { PullRequestStackState } from "./usePullRequestStack"; @@ -66,7 +67,7 @@ export function PullRequestStackCard(props: { selected ? undefined : () => { - const params = { ...props.params, number: layer.number }; + const params = pullRequestLayerParams(props.params, layer.number); if (Platform.OS === "android") selectDetail({ name: "PullRequest", params }); else navigation.dispatch(StackActions.push("PullRequest", params)); diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index 6c675219dc943..02e8b516deccb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -11,6 +11,7 @@ import { useAtomValue } from "@effect/atom-react"; import { Keyboard, Platform, + Pressable, RefreshControl, ScrollView, View, @@ -20,6 +21,7 @@ import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeS import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; +import { SymbolView } from "../../components/ui/AppSymbol"; import { EmptyState } from "../../components/ui/EmptyState"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; @@ -32,7 +34,6 @@ import { usePullRequestListFilters, type PullRequestFilterMenu, } from "./PullRequestListFilters"; -import { PullRequestCardRow } from "./PullRequestControls"; import { usePullRequestList } from "./usePullRequestList"; import { usePullRequestListControls } from "./usePullRequestListControls"; import { usePullRequestListActions } from "./usePullRequestListActions"; @@ -58,26 +59,38 @@ function PullRequestScrollView(props: ScrollViewProps) { ); } -function PullRequestListErrors(props: { readonly messages: readonly string[] }) { +function PullRequestListError(props: { readonly message: string }) { const [expanded, setExpanded] = useState(false); - const title = `${props.messages.length} ${props.messages.length === 1 ? "error" : "errors"} loading pull requests`; return ( - - setExpanded((value) => !value)} + setExpanded((value) => !value)} + // A long press selects the text; collapsing under that selection crashes Android's + // smart-select animation, so it must not also count as a press. + onLongPress={() => undefined} + className="min-h-11 flex-row items-center gap-3 px-4 py-2.5 active:opacity-70" + > + - {expanded ? ( - - {props.messages.join("\n")} - - ) : null} - + + {props.message} + + + ); } @@ -334,13 +347,14 @@ export function PullRequestsScreen( <> {result.data?.errors.length ? ( - - `${error.projectTitle || environments.find((environment) => environment.environmentId === error.environmentId)?.environmentLabel || "Environment"}: ${error.message}`, - )} - /> + + {result.data.errors.map((error) => ( + environment.environmentId === error.environmentId)?.environmentLabel || "Environment"}: ${error.message}`} + /> + ))} + ) : null} } diff --git a/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx b/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx index 6b4eef34abd60..8a35fb7b2ee45 100644 --- a/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx +++ b/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx @@ -39,18 +39,15 @@ export function GuideNotice({ state }: { readonly state: ReturnType {notice.description} - + {state.canWrite || busy || notice.kind === "unavailable" ? ( - - + + ); +} + +it.each([ + { width: 1920, height: 1080, axis: "width", typed: "2560", expected: "2560 × 1440" }, + { width: 390, height: 844, axis: "height", typed: "2532", expected: "1170 × 2532" }, +])("keeps each typed $axis digit until the locked size is committed", async (testCase) => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: `Viewport ${testCase.axis}` }); + await userEvent.clear(input); + await userEvent.type(input, testCase.typed); + await expect.element(input).toHaveValue(Number(testCase.typed)); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent(testCase.expected); +}); + +it("clamps a completed width below the locked minimum when leaving the toolbar", async () => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: "Viewport width" }); + await userEvent.clear(input); + await userEvent.type(input, "256"); + await expect.element(input).toHaveValue(256); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent("427 × 240"); +}); + +it("still limits a completed locked size to the viewport area cap", async () => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: "Viewport width" }); + await userEvent.clear(input); + await userEvent.type(input, "3840"); + await expect.element(input).toHaveValue(3840); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent("2880 × 2880"); +}); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index dd5b707cfebdb..977431aa6ff4d 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -51,6 +51,7 @@ export function BrowserDeviceToolbar({ const [customSize, setCustomSize] = useState<{ readonly width: string; readonly height: string; + readonly axis?: "width" | "height"; } | null>(null); const presentedSize = customSize ?? { width: String(setting.width), @@ -61,15 +62,25 @@ export function BrowserDeviceToolbar({ PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === setting.presetId) ? setting.presetId : RESPONSIVE_VALUE; - const customWidth = Number(presentedSize.width); - const customHeight = Number(presentedSize.height); + const typedWidth = Number(presentedSize.width); + const typedHeight = Number(presentedSize.height); + const { width: customWidth, height: customHeight } = + aspectRatio === null || customSize?.axis === undefined + ? { width: typedWidth, height: typedHeight } + : resizeFreeformViewport( + { width: typedWidth, height: typedHeight }, + { x: 0, y: 0 }, + 1, + customSize?.axis === "height" ? "south" : "east", + aspectRatio, + ); const customValid = - Number.isInteger(customWidth) && - Number.isInteger(customHeight) && - customWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && - customWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && - customHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && - customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && + Number.isInteger(typedWidth) && + Number.isInteger(typedHeight) && + typedWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && + typedWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && + typedHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && + typedHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA; const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => { @@ -96,6 +107,7 @@ export function BrowserDeviceToolbar({ const next = { width: axis === "width" ? value : (current?.width ?? String(setting.width)), height: axis === "height" ? value : (current?.height ?? String(setting.height)), + axis, }; const numeric = Number(value); if ( @@ -115,7 +127,11 @@ export function BrowserDeviceToolbar({ axis === "width" ? "east" : "south", aspectRatio, ); - return { width: String(resized.width), height: String(resized.height) }; + return { + ...next, + width: axis === "width" ? value : String(resized.width), + height: axis === "height" ? value : String(resized.height), + }; }); }; diff --git a/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx new file mode 100644 index 0000000000000..47cfb623dc7f9 --- /dev/null +++ b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx @@ -0,0 +1,222 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { + PreviewStreamControl, + PreviewStreamEvents, + PreviewStreamInput, +} from "@t3tools/client-runtime/preview/server-browser-stream"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { act, render } from "../../test/render"; + +const mocks = vi.hoisted(() => ({ + createClient: vi.fn<(target: unknown, handlers: PreviewStreamEvents) => unknown>(), + sent: [] as PreviewStreamInput[], + access: { url: "ws://preview.test", token: "test" }, + painted: null as (() => void) | null, +})); + +vi.mock(import("@t3tools/client-runtime/preview/server-browser-stream"), async (original) => ({ + ...(await original()), + createPreviewStreamClient: mocks.createClient as never, + createPreviewFramePainter: (_canvas, onPainted) => { + mocks.painted = onPainted ?? null; + return { paint: () => undefined, stop: () => undefined }; + }, +})); + +vi.mock(import("~/state/previewStream"), async (original) => ({ + ...(await original()), + usePreviewStreamAccess: () => mocks.access as never, +})); + +import { ServerBrowserSurface } from "./ServerBrowserSurface"; + +function setup() { + mocks.sent.length = 0; + mocks.painted = null; + mocks.createClient.mockReset().mockImplementation(() => ({ + send: (input: PreviewStreamInput) => mocks.sent.push(input), + stop: () => undefined, + })); +} + +async function renderSurface(controller: PreviewStreamControl["controller"]) { + const container = document.createElement("div"); + container.style.cssText = "width: 400px; height: 300px; display: flex;"; + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( + , + { container }, + ), + ); + await vi.waitFor(() => expect(mocks.createClient).toHaveBeenCalled()); + const handlers = mocks.createClient.mock.calls.at(-1)![1]; + await act(() => + handlers.onControl?.({ canOperate: true, controller, generation: 1, dialog: null }), + ); + mocks.sent.length = 0; + const input = container.querySelector("textarea")!; + const canvas = container.querySelector("canvas")!; + stack.move(); + return { screen, input, canvas }; +} + +const keys = () => mocks.sent.filter((input) => input.type === "key"); + +describe("ServerBrowserSurface clipboard commands", () => { + it("sends a native Copy or Cut menu command to the page once", async () => { + setup(); + const { screen, input, canvas } = await renderSurface("you"); + await using _screen = screen; + // Chromium runs a script Copy or Cut only during a user gesture, as a menu click is. + await userEvent.click(canvas); + input.focus(); + mocks.sent.length = 0; + + // Like Electron's webContents.copy(): the browser's Copy command, with no key. + expect(document.execCommand("copy")).toBe(true); + expect(keys()).toEqual([ + { type: "key", action: "down", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + { type: "key", action: "up", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + ]); + + mocks.sent.length = 0; + expect(document.execCommand("cut")).toBe(true); + expect(input.value).toBe("\u200b"); + expect(keys()).toEqual([ + { type: "key", action: "down", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 }, + { type: "key", action: "up", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 }, + ]); + }); + + it("sends a keyboard Copy or Cut once and leaves menu Copy working after any shortcut", async () => { + setup(); + const { screen, input, canvas } = await renderSurface("you"); + await using _screen = screen; + await userEvent.click(canvas); + input.focus(); + const prevented: boolean[] = []; + const onKeyDown = (event: KeyboardEvent) => { + if (/^[cx]$/i.test(event.key)) prevented.push(event.defaultPrevented); + }; + window.addEventListener("keydown", onKeyDown); + using _listener = { [Symbol.dispose]: () => window.removeEventListener("keydown", onKeyDown) }; + + // An unprevented copy or cut key also makes the browser fire copy or cut: here Chromium's + // Ctrl key binding, on macOS Electron's menu after the Meta keyup. + for (const [shortcut, cancels] of [ + ["{Control>}x{/Control}", true], + ["{Control>}c{/Control}", true], + ["{Meta>}{x>}{/Meta}{/x}", true], + ["{Control>}{Shift>}c{/Shift}{/Control}", false], + ["{Control>}{Alt>}x{/Alt}{/Control}", false], + ] as const) { + mocks.sent.length = 0; + prevented.length = 0; + await userEvent.keyboard(shortcut); + expect(prevented, shortcut).toEqual([cancels]); + expect(keys().filter((key) => /^[cx]$/i.test(key.key)).length, shortcut).toBe(2); + expect(input.value).toBe("\u200b"); + + mocks.sent.length = 0; + expect(document.execCommand("copy")).toBe(true); + expect(keys(), shortcut).toEqual([ + { type: "key", action: "down", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + { type: "key", action: "up", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + ]); + } + }); + + it("sends nothing when another viewer controls the page", async () => { + setup(); + const { screen, input } = await renderSurface("another-viewer"); + await using _screen = screen; + + expect( + input.dispatchEvent(new ClipboardEvent("copy", { bubbles: true, cancelable: true })), + ).toBe(false); + expect( + input.dispatchEvent(new ClipboardEvent("cut", { bubbles: true, cancelable: true })), + ).toBe(false); + expect(mocks.sent).toEqual([]); + }); +}); + +const mouseUp = (canvas: HTMLCanvasElement, button: number) => { + const event = new MouseEvent("mouseup", { button, bubbles: true, cancelable: true }); + canvas.dispatchEvent(event); + return event; +}; + +describe("ServerBrowserSurface history", () => { + it("routes mouse thumb buttons to the preview's history instead of the app", async () => { + setup(); + const { screen, input } = await renderSurface("you"); + await using _screen = screen; + const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!; + + expect(mouseUp(canvas, 3).defaultPrevented).toBe(true); + expect(mouseUp(canvas, 4).defaultPrevented).toBe(true); + expect(mocks.sent).toEqual([ + { type: "history", delta: -1 }, + { type: "history", delta: 1 }, + ]); + }); + + it("leaves other mouse buttons alone", async () => { + setup(); + const { screen, input } = await renderSurface("you"); + await using _screen = screen; + const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!; + + expect(mouseUp(canvas, 0).defaultPrevented).toBe(false); + expect(mocks.sent).toEqual([]); + }); + + it("does not forward thumb-button presses to the page", async () => { + setup(); + const { screen, input } = await renderSurface("you"); + await using _screen = screen; + const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!; + canvas.width = 400; + canvas.height = 300; + // Synthetic pointers have no active pointer id to capture. + canvas.setPointerCapture = () => undefined; + const handlers = mocks.createClient.mock.calls.at(-1)![1]; + await act(() => { + mocks.painted?.(); + handlers.onViewport({ width: 400, height: 300 }); + }); + const { left, top } = canvas.getBoundingClientRect(); + const press = (type: "pointerdown" | "pointerup", button: number) => + canvas.dispatchEvent( + new PointerEvent(type, { + button, + buttons: type === "pointerdown" ? 1 << button : 0, + clientX: left + 10, + clientY: top + 10, + pointerType: "mouse", + bubbles: true, + }), + ); + const mouseInputs = () => mocks.sent.filter((input) => input.type === "mouse"); + + for (const button of [3, 4]) { + press("pointerdown", button); + press("pointerup", button); + } + expect(mouseInputs()).toEqual([]); + + press("pointerdown", 0); + expect(mouseInputs()).not.toEqual([]); + }); +}); diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index c6ad2f51c3f31..e32e8f76f1a9d 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -20,8 +20,10 @@ import type { PreviewViewportSetting, } from "@t3tools/contracts"; import { + type ClipboardEvent, type FormEvent, type KeyboardEvent, + type MouseEvent, type PointerEvent, type Ref, useEffect, @@ -56,6 +58,9 @@ const PROBE_REUSE_MS = 10_000; const INPUT_SENTINEL = "\u200b"; const BACKSPACE = { key: "Backspace", code: "Backspace", keyCode: 8 } as const; const DELETE = { key: "Delete", code: "Delete", keyCode: 46 } as const; +const COPY = { key: "c", code: "KeyC", keyCode: 67 } as const; +const CUT = { key: "x", code: "KeyX", keyCode: 88 } as const; +const CTRL = 2; const MAX_UNAUTHORIZED_REFUSALS = 3; const TAP_SLOP_PX = 8; const MULTI_CLICK_MS = 500; @@ -94,6 +99,10 @@ const buttonOf = (button: number): PreviewStreamMouseButton => const pressedButtonOf = (buttons: number): PreviewStreamMouseButton => buttons & 1 ? "left" : buttons & 2 ? "right" : buttons & 4 ? "middle" : "none"; +// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. +const historyDeltaOf = (button: number): -1 | 1 | null => + button === 3 ? -1 : button === 4 ? 1 : null; + /** Browsers may refuse a write that arrives after the key press; a toast button is a fresh gesture. */ async function copyPageText(text: string) { try { @@ -490,6 +499,8 @@ export function ServerBrowserSurface(props: { const handlePointer = (event: PointerEvent) => { if (controlRef.current?.controller !== "you") return; + // Thumb buttons drive this tab's history in handleMouseUp, never the page. + if (historyDeltaOf(event.button) !== null) return; const { type, pointerId, clientX, clientY } = event; const cancelled = type === "pointercancel"; const touch = touchRef.current; @@ -626,15 +637,36 @@ export function ServerBrowserSurface(props: { } }; + // Chromium runs a thumb button's back/forward on mouseup in this document, which + // would move the app's router. Cancel it and send the press to the preview, as + // the toolbar does. + const handleMouseUp = (event: MouseEvent) => { + const delta = historyDeltaOf(event.button); + if (delta === null) return; + event.preventDefault(); + send({ type: "history", delta }); + }; + const resetInput = (textarea: HTMLTextAreaElement) => { keySentRef.current = false; textarea.value = INPUT_SENTINEL; textarea.setSelectionRange(INPUT_SENTINEL.length, INPUT_SENTINEL.length); }; - const sendKeyPress = (key: typeof BACKSPACE | typeof DELETE) => { - send({ type: "key", action: "down", ...key, modifiers: 0 }); - send({ type: "key", action: "up", ...key, modifiers: 0 }); + const sendKeyPress = ( + key: typeof BACKSPACE | typeof DELETE | typeof COPY | typeof CUT, + modifiers = 0, + ) => { + send({ type: "key", action: "down", ...key, modifiers }); + send({ type: "key", action: "up", ...key, modifiers }); + }; + + // Edit menu Copy and Cut fire no key, so send the shortcut the server copies on. + const handleClipboard = (key: typeof COPY | typeof CUT, event: ClipboardEvent) => { + // Keep the sentinel off the clipboard. + event.preventDefault(); + if (controlRef.current?.controller !== "you") return; + sendKeyPress(key, CTRL); }; const handleKey = (action: "down" | "up", event: KeyboardEvent) => { @@ -679,6 +711,15 @@ export function ServerBrowserSurface(props: { action === "down" && !shortcut && (text !== undefined || event.key === "Backspace" || event.key === "Delete"); + // The key copies or cuts in the page, so stop the browser's own copy or cut repeating it. + if ( + event.ctrlKey !== event.metaKey && + !event.altKey && + !event.shiftKey && + ["c", "x"].includes(event.key.toLowerCase()) + ) { + event.preventDefault(); + } if (shortcut) return; event.preventDefault(); event.stopPropagation(); @@ -703,6 +744,7 @@ export function ServerBrowserSurface(props: {
event.preventDefault()} - onCut={(event) => event.preventDefault()} + onCopy={(event) => handleClipboard(COPY, event)} + onCut={(event) => handleClipboard(CUT, event)} onPaste={(event) => { event.preventDefault(); const text = event.clipboardData.getData("text/plain"); diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 109776a091e94..9c4a6c803ff5b 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -24,7 +24,11 @@ import { setActivePreviewTab, updatePreviewServerSnapshot, } from "~/previewStateStore"; -import { selectSelectedRightPanelSurface, useRightPanelStore } from "~/rightPanelStore"; +import { + rightPanelHostRef, + selectSelectedRightPanelSurface, + useRightPanelStore, +} from "~/rightPanelStore"; import { resolveBrowserDefaults } from "./browserDefaults"; @@ -57,23 +61,25 @@ export async function openUrlInPreview(input: { readonly profileId?: PreviewOpenInput["profileId"]; readonly background?: boolean; }): Promise> { + // Read before any await: a side chat pane that unmounts meanwhile drops its host mapping. + const threadRef = rightPanelHostRef(input.threadRef); const defaults = await resolveBrowserDefaults().catch( (cause: unknown) => new BrowserSettingsReadError({ cause }), ); if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } - const runtime = previewRuntimeFor(input.threadRef.environmentId); - const previousActiveTabId = readThreadPreviewState(input.threadRef).activeTabId; + const runtime = previewRuntimeFor(threadRef.environmentId); + const previousActiveTabId = readThreadPreviewState(threadRef).activeTabId; // The server changes the active tab, not panel selection; selection changes come from the user. const selectedSurface = () => - selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, input.threadRef) - ?.id ?? null; + selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, threadRef)?.id ?? + null; const surfaceBeforeOpen = selectedSurface(); const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, + environmentId: threadRef.environmentId, input: { - threadId: input.threadRef.threadId, + threadId: threadRef.threadId, url: input.url, viewport: defaults.viewport, profileId: input.profileId ?? defaults.profileId, @@ -81,21 +87,21 @@ export async function openUrlInPreview(input: { }, }); return mapAtomCommandResult(result, (snapshot) => { - rememberPreviewUrl(input.threadRef, input.url); + rememberPreviewUrl(threadRef, input.url); if (input.background) { - updatePreviewServerSnapshot(input.threadRef, snapshot); + updatePreviewServerSnapshot(threadRef, snapshot); // Restore focus unless the user picked a tab while opening this one. if ( previousActiveTabId && - readThreadPreviewState(input.threadRef).activeTabId === snapshot.tabId && + readThreadPreviewState(threadRef).activeTabId === snapshot.tabId && selectedSurface() === surfaceBeforeOpen ) { - setActivePreviewTab(input.threadRef, previousActiveTabId); + setActivePreviewTab(threadRef, previousActiveTabId); } return; } - applyPreviewServerSnapshot(input.threadRef, snapshot); - useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + applyPreviewServerSnapshot(threadRef, snapshot); + useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); }); } @@ -124,6 +130,7 @@ export async function openFileInPreview(input: { ), ); } + const hostRef = rightPanelHostRef(input.threadRef); const insideWorkspace = mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined; const assetResult = await input.createAssetUrl({ @@ -146,7 +153,7 @@ export async function openFileInPreview(input: { ); } return openUrlInPreview({ - threadRef: input.threadRef, + threadRef: hostRef, url: assetUrl, openPreview: input.openPreview, }); diff --git a/apps/web/src/closedViewStore.test.ts b/apps/web/src/closedViewStore.test.ts index 0223dfb9d5bd5..3e2cacc57e736 100644 --- a/apps/web/src/closedViewStore.test.ts +++ b/apps/web/src/closedViewStore.test.ts @@ -54,6 +54,12 @@ describe("closedViewStore", () => { threadRef: refA, surface: { kind: "pull-request", id: "pull-request:x", repository: "owner/repo", number: 1 }, }, + { + id: "bad", + kind: "panel-tab", + threadRef: refA, + surface: { kind: "side-chat", id: "side-chat:x" }, + }, ])("discards incomplete saved views %j while retaining valid history", async (entry) => { const id = useClosedViewStore.getState().remember(diff(refA)); const { storage, name } = useClosedViewStore.persist.getOptions(); @@ -85,6 +91,11 @@ describe("closedViewStore", () => { it("removes private metadata and malformed entries from history saved by earlier versions", async () => { const store = useClosedViewStore.getState(); + const sideChatId = store.remember({ + kind: "panel-tab", + threadRef: refA, + surface: { id: "side-chat:side", kind: "side-chat", threadId: ThreadId.make("side") }, + }); const publicId = store.remember(diff(refA)); const browserId = store.remember({ kind: "browser", @@ -115,6 +126,7 @@ describe("closedViewStore", () => { expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([ browserId, publicId, + sideChatId, ]); const saved = JSON.stringify(await storage!.getItem(name!)); expect(saved).not.toContain("private.example"); diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index 76fee83a83d08..b5db7bd741bef 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -83,6 +83,8 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { typeof surface.target.name === "string" && (surface.target.platform === "ios" || surface.target.platform === "android"))) ); + case "side-chat": + return typeof surface.threadId === "string" && surface.id === `side-chat:${surface.threadId}`; case "pull-request": return ( typeof surface.projectId === "string" && diff --git a/apps/web/src/components/AppRouteModal.browser.test.tsx b/apps/web/src/components/AppRouteModal.browser.test.tsx new file mode 100644 index 0000000000000..cb31eeca45ab0 --- /dev/null +++ b/apps/web/src/components/AppRouteModal.browser.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { useCommitOnBlur } from "../hooks/useCommitOnBlur"; +import { AppRouteModal } from "./AppRouteModal"; + +function CommitOnBlurField({ onCommit }: { onCommit: (next: string) => void }) { + return ; +} + +describe("AppRouteModal", () => { + it("commits a focused field when Esc closes it", async () => { + const onCommit = vi.fn(); + await using _screen = await render( + + + , + { router: "/settings/general" }, + ); + + await page.getByRole("textbox", { name: "Instructions" }).fill("Keep titles short"); + await userEvent.keyboard("{Escape}"); + + expect(onCommit).toHaveBeenCalledWith("Keep titles short"); + }); +}); diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx index 89e676da2e3c3..55d7c4112f968 100644 --- a/apps/web/src/components/AppRouteModal.tsx +++ b/apps/web/src/components/AppRouteModal.tsx @@ -27,7 +27,12 @@ export function AppRouteModal({ label, children }: { label: string; children: Re // Portaled layers outside this tree (toasts, Clerk, confirm dialogs) are not backdrop presses. disablePointerDismissal onOpenChange={(nextOpen) => { - if (!nextOpen) setOpen(false); + if (nextOpen) return; + // Closing unmounts a focused field without a blur, so commit-on-blur rows would + // drop what was typed. Blur first so they commit. + const focused = document.activeElement; + if (focused instanceof HTMLElement && popupRef.current?.contains(focused)) focused.blur(); + setOpen(false); }} onOpenChangeComplete={(nextOpen) => { if (!nextOpen) void closeAppModal(); diff --git a/apps/web/src/components/BackgroundThreadAnnouncer.tsx b/apps/web/src/components/BackgroundThreadAnnouncer.tsx index 82c280e6e56fe..3176c8132eed9 100644 --- a/apps/web/src/components/BackgroundThreadAnnouncer.tsx +++ b/apps/web/src/components/BackgroundThreadAnnouncer.tsx @@ -1,5 +1,6 @@ import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { isListedThreadLineage } from "@t3tools/contracts"; import type { AnyRouter } from "@tanstack/react-router"; import type { AtomRegistry } from "effect/reactivity"; @@ -42,9 +43,8 @@ export function startBackgroundThreadAnnouncements( const now = new Date().toISOString(); const snapshots: BackgroundThreadSnapshot[] = []; for (const thread of threads) { - // Subagents have no row of their own; their requests surface on the parent. - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") - continue; + // Subagents and side chats have no row of their own; their requests surface on the parent. + if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) continue; if (!liveEnvironmentIds.has(thread.environmentId)) continue; snapshots.push({ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 4d57bc7332525..93249bcf0c9f2 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -214,9 +214,7 @@ const RunContextSelector = memo(function RunContextSelector({ const workspaceIcon = ( }> - + {isPanel @@ -232,7 +230,7 @@ const RunContextSelector = memo(function RunContextSelector({ }> {autoEnvironmentLabel ? ( + , + ), + ); + + await expect.element(screen.getByText("Review")).toBeVisible(); + const details = [ + ...inserted, + ...mutations.takeRecords().flatMap((record) => [...record.addedNodes]), + ].filter( + (node) => + node instanceof Element && + (node.matches('[aria-label="Show details"]') || + node.querySelector('[aria-label="Show details"]')), + ); + expect(details).toEqual([]); +}); + +it("shows the details button while text or nested text is cut, until it fits again", async () => { + await using screen = await render(noticeRow("4rem", "Waiting on a subagent with a long name")); + const details = screen.getByRole("button", { name: "Show details" }); + await expect.element(details).toBeInTheDocument(); + + await screen.rerender(noticeRow("4rem", "Ready")); + await expect.element(details).not.toBeInTheDocument(); + + await screen.rerender(noticeRow("4rem", "Waiting on a subagent with a long name")); + await expect.element(details).toBeInTheDocument(); + + screen.getByTestId("row").element().style.width = "40rem"; + await expect.element(details).not.toBeInTheDocument(); + + await screen.rerender( + noticeRow( + "40rem", + A long nested name, + ), + ); + await expect.element(details).toBeInTheDocument(); + + await screen.rerender( + + {noticeRow("4rem", "Waiting on a subagent with a long name")} + , + ); + await expect.element(details).toBeInTheDocument(); + await screen.rerender({noticeRow("4rem", "Ready")}); + await expect.element(details).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 755d20ba8c6ae..61778f817dc37 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -59,6 +59,8 @@ export function ComposerBannerStack({ )); } +const SUBPIXEL_ROUNDING = 0.001; + export function NoticeText({ children, className, @@ -70,27 +72,55 @@ export function NoticeText({ }) { const textRef = useRef(null); const detailsRef = useRef(null); - const [showDetails, setShowDetails] = useState(false); + const [detailsAtWidth, setDetailsAtWidth] = useState(null); + const showDetails = detailsAtWidth !== null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures text overflow in the DOM via ResizeObserver and MutationObserver to show the details button useLayoutEffect(() => { const text = textRef.current; if (!text) return; + const banner = text.closest("[data-slot=composer-banner]"); + const range = document.createRange(); const measure = () => { // Ignore the space taken by the details button itself so it cannot // sustain its own overflow after the text would otherwise fit. const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0; const hidden = getComputedStyle(text).position === "absolute"; - setShowDetails( + const overflowing = hidden || - [text, ...text.querySelectorAll("*")].some( - (element) => element.scrollWidth > element.clientWidth + recoveredWidth, - ), + [text, ...text.querySelectorAll("*")].some((element) => { + // A range spans laid-out content only; scrollWidth also counts positioned layers + // such as a link's touch hit area, which overflow without cutting any text. + // The two rects can disagree by float rounding (~0.00001px on phones); real + // overflow is at least one layout unit, 1/64 of a device pixel. + range.selectNodeContents(element); + return ( + range.getBoundingClientRect().width > + element.getBoundingClientRect().width + recoveredWidth + SUBPIXEL_ROUNDING + ); + }); + // The button's width can wrap the banner's actions onto a second row, which gives + // the text room and removes the button, which unwraps the row again. Once shown, + // the button stays until the banner itself gets wider than where it appeared. + const width = banner?.clientWidth ?? text.clientWidth; + setDetailsAtWidth((shownAt) => + overflowing + ? (shownAt ?? width) + : banner && shownAt !== null && width <= shownAt + ? shownAt + : null, ); }; measure(); - const stopObserving = observeResize(text, measure); - const mutations = new MutationObserver(measure); + const stopObserving = observeResize([text, banner ?? text], measure); + let content = text.textContent; + const mutations = new MutationObserver(() => { + if (text.textContent !== content) { + content = text.textContent; + setDetailsAtWidth(null); + } + measure(); + }); mutations.observe(text, { childList: true, subtree: true, characterData: true }); return () => { stopObserving(); @@ -179,7 +209,7 @@ function NoticeCard({ ) : null} {item.actions || item.onDismiss ? ( - + {item.actions} {item.onDismiss ? ( - ), - ...(permissionsChipHidden - ? { - extraMenuHint: [ - `Permissions: ${ - compatibleRuntimeModeOptions.find((option) => option.mode === compatibleRuntimeMode) - ?.label ?? compatibleRuntimeMode - }`, - shortcutLabelForCommand(keybindings, "composer.mode"), - ] - .filter(Boolean) - .join(" · "), - } - : {}), - } satisfies Parameters[0]; - const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); const { accessDisplay, showModeBlock, hiddenBlockCount: restingHiddenBlockCount, hiddenBlockIds: hiddenRestingBlockIds, - traitsHidden: traitsBlockHidden, - effortOwnsModeShortcut, overflowShowsPermissions, + pickerOwnsModeShortcut, } = resolveComposerControlsOwnership({ - matchesDefault: permissionsChipHidden, - hasTraits: providerTraitsPicker !== null, + matchesDefault: accessMatchesDefault || props.permissionsShownInStrip, planModeUiEnabled, collapsed: composerControlsCollapsed, measuredHiddenBlockCount, }); - const restingProviderTraitsPicker = renderProviderTraitsPicker({ - ...providerTraitsPickerInput, - size: composerControlsCollapsed ? "xs" : "sm", - hidden: composerControlsHidden || traitsBlockHidden, - extraMenuOwnsModeShortcut: effortOwnsModeShortcut, + const controlSize = composerControlsCollapsed ? "xs" : "sm"; + const traitsInput = resolveComposerTraitsInput({ + provider: selectedProvider, + instanceId: selectedInstanceId, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), + model: selectedModel, + models: selectedProviderModels, + modelOptions: composerModelOptions?.[selectedInstanceId], + reportedModelSelection, + promptUltrathink, + onPromptEdit, + planModeEnabled, }); - const restingBlockDefs = [ - ...(providerTraitsPicker - ? [ - { - id: "traits", - content: restingProviderTraitsPicker, - }, - ] - : []), - ...(!showModeBlock - ? [] - : [ - { - id: "mode", - content: ( - @@ -156,7 +156,7 @@ export const ComposerFooterModeControls = memo(function ComposerFooterModeContro /** * Permission modes as one horizontal toggle, with the shown mode named below. * Each segment is a real menu radio item, so arrow keys reach it from the rest of the menu - * and a pick closes the menu. Lives inside a Menu: the permissions chip, the effort menu, + * and a pick closes the menu. Lives inside a Menu: the permissions chip, the model picker, * or the overflow menu. */ export function ComposerPermissionsSection(props: { @@ -182,7 +182,7 @@ export function ComposerPermissionsSection(props: { onValueChange={(value: RuntimeMode) => { if (value !== props.runtimeMode) props.onRuntimeModeChange(value); }} - className="flex gap-0.5 rounded-lg bg-input/40 p-0.5" + className="flex rounded-lg bg-input/40 p-0.5" // Menus navigate with Up/Down only; the row reads horizontally, so Left/Right move too. onKeyDown={(event) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; @@ -211,7 +211,7 @@ export function ComposerPermissionsSection(props: { onPointerLeave={() => setPreviewMode(null)} onFocus={() => setPreviewMode(option.mode)} onBlur={() => setPreviewMode(null)} - className="flex h-6 flex-1 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none data-checked:bg-input/72 data-checked:text-foreground data-highlighted:text-foreground not-data-checked:data-highlighted:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" + className="flex h-6 flex-1 cursor-pointer items-center justify-center text-muted-foreground outline-none first:rounded-l-md last:rounded-r-md data-checked:bg-input/72 data-checked:text-foreground data-highlighted:text-foreground not-data-checked:data-highlighted:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" > focusRequestedPermission(open, triggerRef.current)}> - Effort + More - - Low - High + + Chat + Plan { - await using screen = await render(, { router: false }); // no routes involved - const trigger = screen.getByRole("button", { name: "Effort" }); +it("reaches Permissions from the mode items with arrow keys, previews, and picks with Enter", async () => { + await using screen = await render(, { router: false }); // no routes involved + const trigger = screen.getByRole("button", { name: "More" }); await userEvent.tab(); await expect.element(trigger).toHaveFocus(); await userEvent.keyboard("{ArrowDown}"); - await expect.element(page.getByRole("menuitemradio", { name: "Low" })).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio", { name: "Chat" })).toHaveFocus(); await userEvent.keyboard("{ArrowDown}{ArrowDown}"); const supervised = page.getByRole("menuitemradio", { name: "Supervised" }); @@ -66,7 +66,7 @@ it("reaches Permissions from the effort items with arrow keys, previews, and pic await expect.element(page.getByRole("menuitemradio", { name: "Supervised" })).toHaveFocus(); await userEvent.keyboard("{ArrowUp}"); - await expect.element(page.getByRole("menuitemradio", { name: "High" })).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio", { name: "Plan" })).toHaveFocus(); await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}"); await expect.element(screen.getByRole("status")).toHaveTextContent("auto-accept-edits"); @@ -77,14 +77,14 @@ it("reaches Permissions from the effort items with arrow keys, previews, and pic }); it("marks the current mode as checked and closes on Escape", async () => { - await using screen = await render(, { router: false }); // no routes involved + await using screen = await render(, { router: false }); // no routes involved await userEvent.tab(); await userEvent.keyboard("{ArrowDown}"); const fullAccess = page.getByRole("menuitemradio", { name: "Full access" }); await expect.element(fullAccess).toHaveAttribute("aria-checked", "true"); await userEvent.keyboard("{Escape}"); await expect.element(fullAccess).not.toBeInTheDocument(); - await expect.element(screen.getByRole("button", { name: "Effort" })).toHaveFocus(); + await expect.element(screen.getByRole("button", { name: "More" })).toHaveFocus(); }); // The real composer menu focus rule (useComposerMenuProps), with a stub composer handle. @@ -95,19 +95,19 @@ function withComposer(children: ReactNode) { } it("returns focus to the editor after a pick", async () => { - const { handle, ui } = withComposer(); + const { handle, ui } = withComposer(); await using screen = await render(ui, { router: false }); // no routes involved await userEvent.tab(); await userEvent.keyboard("{ArrowDown}"); - await expect.element(page.getByRole("menuitemradio", { name: "Low" })).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio", { name: "Chat" })).toHaveFocus(); await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}"); await expect.element(screen.getByRole("status")).toHaveTextContent("approval-required"); await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled()); }); it("opens on the checked permission when composer.mode marked the trigger", async () => { - await using screen = await render(, { router: false }); // no routes involved - const trigger = screen.getByRole("button", { name: "Effort" }); + await using screen = await render(, { router: false }); // no routes involved + const trigger = screen.getByRole("button", { name: "More" }); markOpenToPermissions(trigger.element() as HTMLElement); await userEvent.click(trigger); await expect.element(page.getByRole("menuitemradio", { name: "Full access" })).toHaveFocus(); @@ -124,7 +124,7 @@ function PermissionsChip() { return (
{mode === "full-access" ? null : ( expect(handle.focusAtEnd).toHaveBeenCalled()); - await expect.element(screen.getByRole("button", { name: "Effort" })).not.toHaveFocus(); + await expect.element(screen.getByRole("button", { name: "Model" })).not.toHaveFocus(); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 965c95d481e99..a4a8993a7036e 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -371,7 +371,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ } >
-
-
-
- {getDisplayModelName(model)} -
- {props.showNewBadge ? ( - - New - - ) : null} - {props.unavailable ? ( - - Unavailable - - ) : null} + {/* One row shape in every tab: the logo names the provider, so Favorites needs no second line. */} + +
+
+ {getDisplayModelName(model)} + {/* The logo is decorative, so name the provider for screen readers. */} + , {props.providerDisplayName}
- {props.showProvider && ( -
- - - {providerLabel} - -
- )} + {props.subProvider ? ( + + {props.subProvider} + + ) : null} + {props.showNewBadge ? ( + + New + + ) : null} + {props.unavailable ? ( + + Unavailable + + ) : null}
@@ -108,9 +114,9 @@ export const ModelListRow = memo(function ModelListRow(props: { diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c5a8cdf64c18f..742a7cf6b09fd 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -21,7 +21,6 @@ import { type ReactNode, type RefObject, } from "react"; -import { ChevronRightIcon } from "lucide-react"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner"; @@ -61,6 +60,7 @@ import { InlineButton } from "../ui/button"; import { isProviderInstancePickerReady, isProviderInstancePickerVisible, + shouldShowInstanceBadge, type ProviderInstanceEntry, } from "../../providerInstances"; import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; @@ -162,10 +162,9 @@ function adjacentModelPickerProvider(input: { } const EMPTY_MODEL_JUMP_LABELS = new Map(); -const MODEL_LIST_ESTIMATED_ITEM_SIZE = 52; +const MODEL_LIST_ESTIMATED_ITEM_SIZE = 32; // Rendered heights in `--spacing` units. Chrome is the search box and list padding, plus a 1px border. -const ONE_LINE_ROW_UNITS = 8; -const TWO_LINE_ROW_UNITS = 12; +const ROW_UNITS = 8; const ROW_SEPARATOR_UNITS = 0.5; const LIST_CHROME_UNITS = 15.5; @@ -363,6 +362,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; /** The owning popup moves focus here when it opens (its `initialFocus`). */ searchInputRef: RefObject; + /** Sits beside an effort column: fills the shared row, and stacks above it on narrow screens. */ + docked?: boolean; + /** Right at the end of the search text moves into the popup's effort column. */ + onFocusEffort?: () => void; }) { const { searchInputRef, @@ -580,31 +583,28 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const isLocked = lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; - // Inside one provider's tab the provider line repeats the selected rail icon on every row. - const rowsMixProviders = isSearching || selectedInstanceId === "favorites"; // Fit the tallest tab so switching tabs never resizes the popup and moves the rail under the cursor. const tallestTabUnits = (() => { const unitsByTab = new Map(); - const addRow = (tab: ProviderInstanceId | "favorites", twoLine: boolean) => { - const rowUnits = twoLine ? TWO_LINE_ROW_UNITS : ONE_LINE_ROW_UNITS; + const addRow = (tab: ProviderInstanceId | "favorites") => { const previous = unitsByTab.get(tab); unitsByTab.set( tab, - previous === undefined ? rowUnits : previous + ROW_SEPARATOR_UNITS + rowUnits, + previous === undefined ? ROW_UNITS : previous + ROW_SEPARATOR_UNITS + ROW_UNITS, ); }; const tabsWithLegacyRow = new Set(); for (const model of flatModels) { if (!matchesLockedProvider(model)) continue; const isFavorite = favoritesSet.has(providerModelKey(model.instanceId, model.slug)); - if (isFavorite) addRow("favorites", true); + if (isFavorite) addRow("favorites"); if (model.isLegacy && !isFavorite) { tabsWithLegacyRow.add(model.instanceId); } else { - addRow(model.instanceId, Boolean(model.subProvider)); + addRow(model.instanceId); } } - for (const instanceId of tabsWithLegacyRow) addRow(instanceId, true); + for (const instanceId of tabsWithLegacyRow) addRow(instanceId); return Math.max(0, ...unitsByTab.values()); })(); const lockedDisabledInstanceIds = (() => { @@ -747,11 +747,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const modelKeys = visibleModels.map((model) => modelPickerModelKey(model.instanceId, model.slug), ); - if (!legacySection) { - return modelKeys; - } - modelKeys.splice(legacySection.currentModels.length, 0, legacySection.key); - return modelKeys; + // The toggle trails the list, so expanding it reveals older models above it. + return legacySection ? [...modelKeys, legacySection.key] : modelKeys; })(); const filteredModelByKey = ((): ReadonlyMap => new Map( @@ -765,7 +762,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { keybindings, props.terminalOpen, ); + const badgedInstanceIds = new Set( + instanceEntries + .filter((entry) => shouldShowInstanceBadge(entry, instanceEntries)) + .map((entry) => entry.instanceId), + ); const modelListExtraData = { + badgedInstanceIds, favoritesSet, modelJumpLabelByKey, activeModelKey, @@ -773,7 +776,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { + if ( + event.defaultPrevented || + event.repeat || + event.isComposing || + event.keyCode === 229 || + isCommandPaletteOpen() + ) { return; } @@ -827,7 +836,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{ + if (e.nativeEvent.isComposing || e.keyCode === 229) { + (e as typeof e & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + e.stopPropagation(); + return; + } if ( showSidebar && !e.altKey && @@ -940,6 +959,29 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onRequestClose?.(); return; } + if ( + props.onFocusEffort && + e.key === "ArrowRight" && + !e.altKey && + !e.ctrlKey && + !e.metaKey && + !e.shiftKey && + e.currentTarget.selectionStart === searchQuery.length + ) { + e.preventDefault(); + e.stopPropagation(); + // The column shows the selected model's effort, so a highlighted model is picked + // first; the owning popup then moves focus to that model's levels. + const highlighted = highlightedModelKeyRef.current + ? parseModelPickerModelKey(highlightedModelKeyRef.current) + : null; + const pickedNew = + highlighted !== null && + highlightedModelKeyRef.current !== activeModelKey && + handleModelSelect(highlighted.slug, highlighted.instanceId) !== null; + if (!pickedNew) props.onFocusEffort(); + return; + } if (e.key === "Enter" && highlightedModelKeyRef.current) { (e as typeof e & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); e.preventDefault(); @@ -977,18 +1019,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { aria-expanded={legacySection.isExpanded} className="group w-full cursor-pointer" > -
-
Legacy models
-
- {legacySection.legacyModels.length} models -
-
- + + {legacySection.isExpanded ? "Show less" : "Show more"} + ); } @@ -1017,7 +1050,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ? selectedModelKeySet.has(modelKey) : modelKey === activeModelKey } - showProvider={rowsMixProviders || Boolean(model.subProvider)} + favoriteStarOnHover={selectedInstanceId === "favorites" && !isSearching} + showInstanceBadge={badgedInstanceIds.has(model.instanceId)} showNewBadge={model.badge === "new"} unavailable={model.isUnavailable === true} jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx new file mode 100644 index 0000000000000..b6dd1a38e86cf --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx @@ -0,0 +1,171 @@ +import { ProviderDriverKind, ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; +import { useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; + +import { render } from "../../../test/render"; +import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { ProviderModelPicker } from "./ProviderModelPicker"; + +it("keeps each registry image when adding a model from another ACP agent", async () => { + const icons = [ + "https://cdn.agentclientprotocol.com/registry/icons/picker-explicit.svg", + "https://cdn.agentclientprotocol.com/registry/v1/latest/picker-by-id.svg", + ]; + const cache = await caches.open("t3-acp-registry-icons-v1"); + await using _cachedIcons = { + async [Symbol.asyncDispose]() { + await Promise.all(icons.map((url) => cache.delete(url))); + }, + }; + for (const [index, url] of icons.entries()) { + await cache.put( + url, + new Response( + ``, + { headers: { "content-type": "image/svg+xml" } }, + ), + ); + } + const entries = deriveProviderInstanceEntries( + ["First", "Second"].map((name) => ({ + instanceId: ProviderInstanceId.make(name.toLowerCase()), + driver: ProviderDriverKind.make("acpRegistry"), + displayName: `${name} agent`, + enabled: true, + installed: true, + version: null, + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { slug: name.toLowerCase(), name: `${name} model`, isCustom: false, capabilities: {} }, + ], + slashCommands: [], + skills: [], + })), + ).map((entry, index) => ({ + ...entry, + ...(index === 0 ? { acpRegistryIconUrl: icons[0] } : { acpRegistryAgentId: "picker-by-id" }), + })); + const first = { instanceId: entries[0]!.instanceId, model: "first" }; + const options = new Map(entries.map((entry) => [entry.instanceId, entry.models])); + + function Picker() { + const [selection, setSelection] = useState>([first]); + return ( + 1 ? { selectedModels: selection } : {})} + lockedProvider={null} + instanceEntries={entries} + modelOptionsByInstance={options} + onToggleModel={(instanceId, model) => setSelection([...selection, { instanceId, model }])} + onInstanceModelChange={(instanceId, model) => setSelection([{ instanceId, model }])} + /> + ); + } + + await using screen = await render(); + const imageWidths = () => + Array.from( + screen.container.querySelectorAll("[data-chat-provider-model-picker] img"), + (image) => image.naturalWidth, + ); + await vi.waitFor(() => expect(imageWidths()).toEqual([11])); + await screen.getByRole("button", { name: "First model", exact: true }).click(); + await page.getByRole("button", { name: "Second agent", exact: true }).click(); + await userEvent.keyboard("{Shift>}"); + try { + await page.getByRole("option", { name: /Second model/ }).click(); + } finally { + await userEvent.keyboard("{/Shift}"); + } + await userEvent.keyboard("{Escape}"); + await vi.waitFor(() => expect(imageWidths()).toEqual([11, 17])); +}); + +it("keeps a docked picker open after a pick and hands focus to that model's effort", async () => { + const instanceId = ProviderInstanceId.make("codex"); + const entries = deriveProviderInstanceEntries([ + { + instanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ]); + const options = new Map([ + [ + instanceId, + [ + { slug: "original", name: "Original model" }, + { slug: "second", name: "Second model" }, + { slug: "plain", name: "Plain model" }, + ], + ], + ]); + // The last level is the selected one; "plain" has no effort to pick. + const levels: Record> = { + original: ["Low", "High"], + second: ["Medium"], + plain: [], + }; + + function Picker() { + const [model, setModel] = useState("original"); + return ( + setModel(selected)} + effortColumn={(levels[model] ?? []).map((level, index, all) => ( + + ))} + /> + ); + } + + await using screen = await render(); + await screen.getByRole("button", { name: "Choose model" }).click(); + const search = page.getByPlaceholder("Search models..."); + await expect.element(search).toHaveFocus(); + await userEvent.keyboard("{ArrowRight}"); + await expect.element(page.getByRole("radio", { name: "High" })).toHaveFocus(); + await userEvent.keyboard("{ArrowLeft}"); + await expect.element(search).toHaveFocus(); + + await page.getByRole("option", { name: /Second model/ }).click(); + await expect.element(page.getByRole("radio", { name: "Medium" })).toHaveFocus(); + + await page.getByRole("option", { name: /Plain model/ }).click(); + await page.getByRole("option", { name: /Second model/ }).click(); + await expect.element(page.getByRole("radio", { name: "Medium" })).toHaveFocus(); + await userEvent.keyboard("{Escape}"); + await expect.element(search).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx new file mode 100644 index 0000000000000..a4dbe8bd31398 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx @@ -0,0 +1,120 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; +import { useState } from "react"; +import { expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { act, render } from "../../../test/render"; +import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { ProviderModelPicker } from "./ProviderModelPicker"; + +const instanceId = ProviderInstanceId.make("codex"); + +function Picker({ keybindings = [] }: { keybindings?: ResolvedKeybindingsConfig }) { + const [model, setModel] = useState("original"); + return ( + <> + {model} + setModel(selected)} + /> + + ); +} + +it("keeps composition keys out of model selection and dismissal", async () => { + await using screen = await render(); + await page.getByRole("button", { name: "Choose model" }).click(); + const search = page.getByPlaceholder("Search models..."); + await search.fill("Target"); + await expect.element(page.getByRole("option", { name: /Target model/ })).toBeVisible(); + + for (const nativeEvent of [{ isComposing: true }, { keyCode: 229 }]) { + for (const key of ["Enter", "Escape"]) { + const event = new KeyboardEvent("keydown", { + key, + ...nativeEvent, + bubbles: true, + cancelable: true, + }); + await act(() => search.element().dispatchEvent(event)); + expect(event.defaultPrevented).toBe(false); + await expect.element(search).toBeVisible(); + await expect + .element(screen.getByRole("status", { name: "Selected model" })) + .toHaveTextContent("original"); + } + } + + await userEvent.keyboard("{Enter}"); + await expect.element(search).not.toBeInTheDocument(); + await expect + .element(screen.getByRole("status", { name: "Selected model" })) + .toHaveTextContent("target"); + await page.getByRole("button", { name: "Choose model" }).click(); + await expect.element(search).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await expect.element(search).not.toBeInTheDocument(); +}); + +it("keeps composing keys from triggering model jump shortcuts", async () => { + const keybindings = compileResolvedKeybindingsConfig([ + { key: "enter", command: "modelPicker.jump.1", when: "modelPickerOpen" }, + ]); + await using screen = await render(); + await page.getByRole("button", { name: "Choose model" }).click(); + const search = page.getByPlaceholder("Search models..."); + await search.fill("Target"); + await expect.element(page.getByRole("option", { name: /Target model/ })).toBeVisible(); + + for (const nativeEvent of [{ isComposing: true }, { keyCode: 229 }]) { + const event = new KeyboardEvent("keydown", { + key: "Enter", + ...nativeEvent, + bubbles: true, + cancelable: true, + }); + await act(() => search.element().dispatchEvent(event)); + expect(event.defaultPrevented).toBe(false); + await expect.element(search).toBeVisible(); + await expect + .element(screen.getByRole("status", { name: "Selected model" })) + .toHaveTextContent("original"); + } +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 83e028a9bbcd2..cfc74a515e3ab 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -5,7 +5,7 @@ import { type ProviderDriverKind, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; -import { memo, useRef, useState } from "react"; +import { createContext, memo, type ReactNode, useRef, useState } from "react"; import { Badge } from "../ui/badge"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -16,7 +16,7 @@ import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ModelEsque, getDisplayModelName, getTriggerDisplayModelName } from "./providerIconUtils"; import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, type ComposerControlSize } from "./ComposerControl"; -import { useComposerMenuProps } from "./composerEventScope"; +import { takeOpenTarget, useComposerMenuProps } from "./composerEventScope"; import { shortcutLabelForCommand } from "../../keybindings"; /** Holds the page still behind the open picker; its lifetime is the popup element's. */ @@ -35,7 +35,10 @@ function lockBackgroundScroll(popup: HTMLElement | null) { } const shouldAllowOverlayScroll = (target: EventTarget | null) => { - return target instanceof Element && target.closest("[data-model-picker-content]"); + return ( + target instanceof Element && + target.closest("[data-model-picker-content], [data-model-picker-effort]") + ); }; const preventBackgroundWheel = (event: WheelEvent) => { if (shouldAllowOverlayScroll(event.target)) { @@ -65,6 +68,9 @@ function lockBackgroundScroll(popup: HTMLElement | null) { }; } +/** What a docked effort column needs from its picker: the model it serves. */ +export const DockedPickerContext = createContext<{ modelLabel: string }>({ modelLabel: "" }); + export const ProviderModelPicker = memo(function ProviderModelPicker(props: { environmentId: EnvironmentId | null; activeInstanceId: ProviderInstanceId; @@ -91,12 +97,27 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; + /** Shown after the model name inside the same trigger, such as the effort level. */ + triggerSuffix?: ReactNode; + /** Names the suffix for the tooltip and screen readers. */ + triggerDetail?: string | null; + /** Composer shortcuts this trigger answers besides the picker's own. */ + triggerShortcuts?: string; + /** + * Docks an effort column beside the list and a footer under both. The popup then stays open + * until Escape or an outside press, and picking a model moves focus to its levels. + */ + effortColumn?: ReactNode; + footer?: ReactNode; }) { const composerFloatingLayerProps = useComposerMenuProps(); const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; const searchInputRef = useRef(null); + const triggerRef = useRef(null); + const popupRef = useRef(null); const size = props.size ?? "sm"; + const docked = props.effortColumn !== undefined; const activeEntry = props.instanceEntries.find((entry) => entry.instanceId === props.activeInstanceId) ?? null; @@ -130,10 +151,39 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { } }; + /** + * Focuses the selected reasoning level, else the first level. With `rows: "any"` it falls back + * to the column's other options. False when nothing took focus. + */ + const focusEffort = (rows: "levels" | "any") => { + const column = popupRef.current?.querySelector("[data-model-picker-effort]"); + const first = (selector: string) => + column?.querySelector(`${selector}[aria-checked="true"]:not(:disabled)`) ?? + column?.querySelector(`${selector}:not(:disabled)`); + const row = + first("[data-effort-level]") ?? (rows === "any" ? first("[data-effort-row]") : null); + row?.focus({ preventScroll: true }); + return row != null; + }; + const openTarget = (target: "effort" | "permissions") => { + if (target === "effort") { + focusEffort("any"); + return; + } + popupRef.current + ?.querySelector("[data-composer-permissions-trigger]") + ?.click(); + }; + const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => { if (props.disabled) return; props.onInstanceModelChange(instanceId, model); - setIsMenuOpen(false); + if (!docked) { + setIsMenuOpen(false); + return; + } + // The column renders the new model's levels on commit. + window.requestAnimationFrame(() => focusEffort("levels")); }; const shortcutLabel = props.keybindings @@ -169,9 +219,51 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const allModelNames = selectedEntries ? selectedEntries.map((selection) => selection.fullLabel).join(", ") || "Choose models" : undefined; - const triggerTooltipContent = shortcutLabel - ? `${props.triggerLabel ?? allModelNames ?? triggerLabel} · ${shortcutLabel}` - : (props.triggerLabel ?? allModelNames ?? triggerLabel); + const triggerTooltipContent = [ + props.triggerLabel ?? allModelNames ?? triggerLabel, + props.triggerDetail, + shortcutLabel, + ] + .filter(Boolean) + .join(" · "); + + const triggerText = props.triggerLabel ?? multipleLabel ?? triggerTitle; + const triggerTextProps = { + className: "min-w-0 flex-1 overflow-hidden truncate", + "data-chat-provider-model-picker-label": "true", + } as const; + + const picker = ( + { + if (!props.disabled) props.onToggleModel?.(instanceId, model); + }, + } + : {})} + lockedProvider={props.lockedProvider} + lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} + instanceEntries={props.instanceEntries} + {...(props.keybindings ? { keybindings: props.keybindings } : {})} + modelOptionsByInstance={props.modelOptionsByInstance} + terminalOpen={props.terminalOpen ?? false} + onRequestClose={() => setIsMenuOpen(false)} + {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})} + getModelDisabledReason={(instanceId, model) => + props.disabled + ? "Model picker is disabled" + : (props.getModelDisabledReason?.(instanceId, model) ?? null) + } + onInstanceModelChange={handleInstanceModelChange} + {...(docked ? { docked, onFocusEffort: () => void focusEffort("any") } : {})} + /> + ); return ( { + const target = takeOpenTarget(triggerRef.current); + if (open && docked && target) openTarget(target); + }} > { + if (!isMenuOpen || !docked) return; + const target = takeOpenTarget(triggerRef.current); + if (!target) return; + // A shortcut aimed at the open picker moves within it instead of closing it. + ( + event as typeof event & { preventBaseUIHandler?: () => void } + ).preventBaseUIHandler?.(); + openTarget(target); + }} className={cn( "min-w-0 shrink justify-between whitespace-nowrap", props.compact @@ -221,6 +332,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { driverKind={selection.entry.driverKind} displayName={selection.entry.displayName} accentColor={selection.entry.accentColor} + acpRegistryAgentId={selection.entry.acpRegistryAgentId} + acpRegistryIconUrl={selection.entry.acpRegistryIconUrl} className="size-4 rounded-full bg-(--chat-composer-glass-surface,var(--background)) ring-2 ring-(--chat-composer-glass-surface,var(--background))" iconClassName="size-4" /> @@ -252,19 +365,15 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /> ) : null} - - - } - > - {props.triggerLabel ?? multipleLabel ?? triggerTitle} - - {triggerTooltipContent} - + {props.isComposerOwned ? ( + {triggerText} + ) : ( + + }>{triggerText} + {triggerTooltipContent} + + )} + {props.triggerSuffix} {selectedModel?.isUnavailable && !selectedEntries && props.triggerLabel === undefined ? ( Unavailable @@ -274,41 +383,45 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { { + popupRef.current = popup; + const unlock = lockBackgroundScroll(popup); + return () => { + popupRef.current = null; + unlock?.(); + }; + }} initialFocus={searchInputRef} align="start" className="before:hidden" padding="none" touchBackdrop > - { - if (!props.disabled) props.onToggleModel?.(instanceId, model); - }, - } - : {})} - lockedProvider={props.lockedProvider} - lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} - instanceEntries={props.instanceEntries} - {...(props.keybindings ? { keybindings: props.keybindings } : {})} - modelOptionsByInstance={props.modelOptionsByInstance} - terminalOpen={props.terminalOpen ?? false} - onRequestClose={() => setIsMenuOpen(false)} - {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})} - getModelDisabledReason={(instanceId, model) => - props.disabled - ? "Model picker is disabled" - : (props.getModelDisabledReason?.(instanceId, model) ?? null) - } - onInstanceModelChange={handleInstanceModelChange} - /> + {props.effortColumn ? ( + +
+
+ {picker} +
{ + if (event.key !== "ArrowLeft" || event.altKey || event.ctrlKey || event.metaKey) + return; + event.preventDefault(); + event.stopPropagation(); + searchInputRef.current?.focus(); + }} + > + {props.effortColumn} +
+
+ {props.footer} +
+
+ ) : ( + picker + )} {props.selectedModels === undefined ? ( ) : null} diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 7abe140698910..6ca50c824cb45 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -10,8 +10,10 @@ import type { } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { lazy, Suspense } from "react"; +import SideChatPane from "./SideChatPane"; import type { ComposerImageAttachment, DraftId } from "../../composerDraftStore"; import type { ShortcutMatchContext } from "../../keybindings"; +import type { SideChatParentPanel } from "../../lib/sideChatPane"; import { type RightPanelSurface, useRightPanelStore } from "../../rightPanelStore"; import { PersistentThreadTerminalPanel, @@ -75,6 +77,7 @@ export function RightPanelSurfaceContent(props: { readonly terminal: RightPanelTerminalProps; readonly pullRequest: RightPanelPullRequestProps; readonly files: RightPanelFileProps; + readonly sideChatParentPanel: SideChatParentPanel; }) { const { surface, threadRef, visible } = props; if (surface === null) return null; @@ -165,6 +168,17 @@ export function RightPanelSurfaceContent(props: { } case "pull-requests": return ; + case "side-chat": + // Not lazy: revealing a suspended tiptap editor tears down its view and crashes the composer. + return ( + props.onCloseSurface(surface)} + /> + ); case "device": return ( diff --git a/apps/web/src/components/chat/SecretRequestCard.tsx b/apps/web/src/components/chat/SecretRequestCard.tsx index 50992c2edebf5..2ec2923b88bbd 100644 --- a/apps/web/src/components/chat/SecretRequestCard.tsx +++ b/apps/web/src/components/chat/SecretRequestCard.tsx @@ -102,9 +102,13 @@ function PendingSecretRequestForm(props: { className="flex min-w-0 flex-col gap-3 rounded-xl border border-border/60 bg-card p-4" onSubmit={onSubmit} autoComplete="off" - onPointerDown={(event) => { - if (!(event.target instanceof HTMLElement)) return; + // On click, not pointerdown: the mousedown that follows a pointerdown + // moves focus back off the input. A click that ends a text selection + // leaves it alone so the reason can still be copied. + onClick={(event) => { + if (!(event.target instanceof Element)) return; if (event.target.closest("input, button, a")) return; + if (window.getSelection()?.isCollapsed === false) return; document.getElementById(inputId)?.focus(); }} > @@ -112,7 +116,9 @@ function PendingSecretRequestForm(props: { - {item.reason.trim() ?

{item.reason}

: null} + {item.reason.trim() ? ( +

{item.reason}

+ ) : null}
diff --git a/apps/web/src/components/chat/SideChatPane.tsx b/apps/web/src/components/chat/SideChatPane.tsx new file mode 100644 index 0000000000000..1a5bd1141b31f --- /dev/null +++ b/apps/web/src/components/chat/SideChatPane.tsx @@ -0,0 +1,179 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; +import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + type ScopedThreadRef, + type ThreadId, +} from "@t3tools/contracts"; +import { Trash2Icon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { ComposerHandleContext } from "../../composerHandleContext"; +import { useClientSettings } from "../../hooks/useSettings"; +import type { SideChatParentPanel } from "../../lib/sideChatPane"; +import { readLocalApi } from "../../localApi"; +import { hostRightPanel, useRightPanelStore } from "../../rightPanelStore"; +import { useQuietThreadProjection, useQuietThreadShell } from "../../state/entities"; +import { useEnvironmentScope } from "../../state/session"; +import { useEnvironmentShellHasSnapshot } from "../../state/shell"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import ChatView from "../ChatView"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { BackgroundThreadContext } from "./backgroundThread"; + +/** A side chat tab: its title, Bring back, Delete and Close, over the side chat's own chat. */ +export default function SideChatPane(props: { + readonly parentRef: ScopedThreadRef; + readonly threadId: ThreadId; + readonly parentPanel: SideChatParentPanel; + readonly onClose: () => void; +}) { + const { parentRef, threadId } = props; + const threadRef = scopeThreadRef(parentRef.environmentId, threadId); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers the parent panel as host in the panel store + useEffect( + () => hostRightPanel(scopeThreadRef(parentRef.environmentId, threadId), parentRef), + [parentRef, threadId], + ); + const shell = useQuietThreadShell(threadRef); + const parent = useQuietThreadShell(parentRef); + const snapshotLoaded = useEnvironmentShellHasSnapshot(parentRef.environmentId); + const projection = useQuietThreadProjection(shell === null ? null : threadRef)?.projection; + const canOperate = useEnvironmentScope(parentRef.environmentId, AuthOrchestrationOperateScope); + const mergeBack = useAtomCommand(threadEnvironment.mergeBack, { reportFailure: false }); + const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); + const [bringingBack, setBringingBack] = useState(false); + const [deleting, setDeleting] = useState(false); + const mergeRun = projection ? resolveLatestMergeBackRun(projection) : null; + // A pending merge-back would fail the main thread's queued message, so the server refuses it. + const bringBackBlockedReason = !canOperate + ? "This connection cannot change threads." + : threadRuntimeIsActive(parent?.runtime) + ? "Available when the main thread is idle." + : mergeRun === null + ? "Available after a finished reply." + : null; + + const bringBack = async () => { + if (bringBackBlockedReason !== null || mergeRun === null || bringingBack) return; + setBringingBack(true); + const result = await mergeBack({ + environmentId: parentRef.environmentId, + input: { sourceThreadId: threadId, targetThreadId: parentRef.threadId, runId: mergeRun.id }, + }); + setBringingBack(false); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Brought back", + description: "The main thread gets this side chat with your next message there.", + }); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not bring back the side chat", + ...(error instanceof Error ? { description: error.message } : {}), + }); + } + }; + + const deleteSideChat = async () => { + if (!canOperate || shell === null || deleting) return; + if (confirmThreadDelete) { + const api = readLocalApi(); + if (!api) return; + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete side chat "${shell.title}"?`, + "This permanently clears its conversation history.", + ].join("\n"), + { variant: "destructive" }, + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + setDeleting(true); + const result = await deleteThread({ + environmentId: parentRef.environmentId, + input: { threadId }, + }); + setDeleting(false); + if (result._tag === "Success") { + useRightPanelStore.getState().closeSurface(parentRef, `side-chat:${threadId}`); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not delete the side chat", + ...(error instanceof Error ? { description: error.message } : {}), + }); + } + }; + + return ( +
+
+ + {shell?.title ?? "Side chat"} + + + void bringBack()} + > + Bring back + + } + /> + + {bringBackBlockedReason ?? "Adds this side chat to the main thread's next message."} + + + + +
+ {shell === null ? ( + snapshotLoaded ? ( +

This side chat was deleted.

+ ) : null + ) : ( + + + + + + )} +
+ ); +} diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 8bd26e21a04dc..4c0af2d3472c0 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -30,12 +30,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; import type { ThreadNowItem } from "./ThreadNow"; -/** Subagents waiting on the user, unless the parent is itself waiting on them. */ +/** Subagents and side chats waiting on the user, unless the parent is itself waiting on them. */ export function childInputNowItem(input: { readonly childThreadInputs: ReadonlyArray; readonly parentAwaitingUser: boolean; readonly onOpenThread: (threadId: ThreadId) => void; -}): ThreadNowItem | null { +}): (ThreadNowItem & { readonly waitingLabel: string }) | null { const { childThreadInputs, onOpenThread } = input; const first = childThreadInputs[0]; if (!first || input.parentAwaitingUser) return null; @@ -48,6 +48,9 @@ export function childInputNowItem(input: { ), title: childThreadInputsTitle(childThreadInputs), + waitingLabel: childThreadInputs.every((child) => child.lineage.sideChat === true) + ? "Side chat waiting" + : "Subagent waiting", description: childThreadInputs.map((child, index) => ( {index > 0 ? " " : null} diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index a5df136f14615..9ffbc90de753d 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -78,10 +78,10 @@ export function ThreadDetailsCard({ const measure = () => { const frame = element.closest("[data-thread-details-card]"); const next = element.offsetHeight + (frame ? frame.offsetHeight - frame.clientHeight : 0); - // PR lists and lineage scroll as they expand. Counting them toward density would hide - // the section and workspace controls when the user asks to see more rows. + // PR lists, lineage and side chats scroll as they expand. Counting them toward density + // would hide the section and workspace controls when the user asks to see more rows. const lists = element.querySelectorAll( - "[data-thread-relationships-panel], [data-thread-details-pr-list]", + "[data-thread-relationships-panel], [data-thread-side-chats-panel], [data-thread-details-pr-list]", ); const fittingHeight = next - Array.from(lists).reduce((sum, list) => sum + list.offsetHeight, 0); diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 35a954db9d43e..5c648016ff502 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -23,7 +23,11 @@ import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; import { ThreadServersPanel } from "./ThreadServersPanel"; -import { ThreadDetailsUsage, type ThreadDetailsUsageInput } from "./ThreadDetailsUsage"; +import { + ThreadDetailsContext, + ThreadDetailsUsage, + type ThreadDetailsUsageInput, +} from "./ThreadDetailsUsage"; import { ThreadDetailsItemsMenuItems } from "./ThreadDetailsItemsMenu"; import { resolveThreadDetailsVisibility, THREAD_DETAILS_ITEMS } from "./threadDetailsItems"; import { useClientSettings } from "../../hooks/useSettings"; @@ -245,15 +249,21 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { /> ) : null} - {density === "full" && !props.draftId && visible.lineage ? ( + {density === "full" && !props.draftId && (visible.lineage || visible.sideChats) ? ( ) : null} {visible.usage ? : null} + {visible.context && props.usage.contextWindow ? ( + + ) : null} + {/* Shown only when no section above has content, so an emptied card can be refilled. */}
= { @@ -35,16 +36,14 @@ export interface ThreadDetailsUsageInput { readonly contextWindow: ContextWindowSnapshot | null; } -/** - * The selected provider's limit windows and the thread's context window. - * Every row reads as room left, so a longer bar always means more headroom. - */ +type UsageTone = "error" | "warning" | null; + +/** The selected provider's limit windows. Each row reads as room left. */ export function ThreadDetailsUsage({ providerInstanceId, providers, sources, - contextWindow, -}: ThreadDetailsUsageInput) { +}: Omit) { const nowMinute = useNowMinute(); const now = Date.parse(`${nowMinute}:00Z`); const report = @@ -56,28 +55,83 @@ export function ThreadDetailsUsage({ const limits = report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? providers.find((provider) => provider.instanceId === providerInstanceId)?.usageLimits; - const notice = limits ? limitsNotice(limits) : null; - if (!limits && !contextWindow) return null; + if (!limits) return null; + const notice = limitsNotice(limits); return (
{notice ? ( {notice}} /> - ) : null} - {limits && !notice - ? limits.windows.map((window) => ) - : null} - {contextWindow ? : null} + ) : ( + limits.windows.map((window) => ) + )} +
+
+ ); +} + +/** The thread's context window. The bar fills as context is used; the tick marks auto-compaction. */ +export function ThreadDetailsContext({ usage }: { readonly usage: ContextWindowSnapshot }) { + const maxTokens = usage.maxTokens != null && usage.maxTokens > 0 ? usage.maxTokens : null; + const used = + maxTokens !== null && usage.usedPercentage !== null + ? Math.round(Math.max(0, Math.min(100, usage.usedPercentage))) + : null; + const threshold = usage.autoCompactThreshold ?? null; + const tick = + maxTokens !== null && threshold !== null && threshold > 0 && threshold < maxTokens + ? (threshold / maxTokens) * 100 + : null; + const usedTokens = formatContextWindowTokens(usage.usedTokens); + return ( + +
+ = 90 ? "error" : used >= 80 ? "warning" : null} + tick={tick} + muted + value={ + maxTokens !== null + ? `${usedTokens} / ${formatContextWindowTokens(maxTokens)}` + : usedTokens + } + ariaValue={used !== null ? `${used}% used` : `${usedTokens} tokens used`} + tooltip={ + <> + + {used !== null ? `Context: ${used}% used` : `Context: ${usedTokens} tokens used`} + + {maxTokens !== null ? ( + + {usage.usedTokens.toLocaleString("en-US")} of {maxTokens.toLocaleString("en-US")}{" "} + tokens + + ) : null} + {threshold !== null && threshold > 0 ? ( + + {formatContextWindowCompactionMessage(null, threshold)} + + ) : null} + + } + />
); @@ -97,7 +151,10 @@ function LimitRow({ return ( @@ -116,76 +173,43 @@ function LimitRow({ ); } -function ContextRow({ - usage, - spaced, -}: { - readonly usage: ContextWindowSnapshot; - readonly spaced: boolean; -}) { - const maxTokens = usage.maxTokens ?? null; - const left = - maxTokens !== null && usage.usedPercentage !== null - ? Math.round(100 - Math.max(0, Math.min(100, usage.usedPercentage))) - : null; - const used = formatContextWindowTokens(usage.usedTokens); - return ( - - {left !== null ? Context: {left}% left : null} - - {maxTokens !== null - ? `${used} of ${formatContextWindowTokens(maxTokens)} tokens used` - : `${used} tokens used`} - - - } - value={left === null ? used : undefined} - /> - ); -} - /** One label, hairline, and value row; the whole row opens the details tooltip. */ function UsageRow({ label, - left, - tooltip, + fill, + tone, + tick = null, value, + ariaValue = value, + tooltip, muted = false, - className, }: { readonly label: string; - readonly left: number | null; + /** Bar fill from 0 to 100. Null hides the bar. */ + readonly fill: number | null; + readonly tone: UsageTone; + /** A marker on the bar, from 0 to 100. */ + readonly tick?: number | null; + readonly value: string; + readonly ariaValue?: string; readonly tooltip: ReactNode; - /** Replaces the percentage when there is none. */ - readonly value?: string | undefined; readonly muted?: boolean; - readonly className?: string | undefined; }) { - const tone = left === null ? null : left <= 10 ? "error" : left <= 20 ? "warning" : null; return ( } > {label} - {left !== null ? ( + {fill !== null ? ( - {left > 0 ? ( + {fill > 0 ? ( + ) : null} + {tick !== null ? ( + ) : null} @@ -206,17 +237,17 @@ function UsageRow({ className={cn( // Text-only rows span the bar column so they never widen the shared value column. "text-end font-medium tabular-nums", - left === null && "col-span-2", + fill === null && "col-span-2", tone === "error" ? "text-error-foreground" : tone === "warning" ? "text-warning-foreground" - : left === null + : fill === null ? "text-muted-foreground" : "text-foreground/80", )} > - {value ?? `${left}%`} + {value} {/* Rows sit 8px inside the card; clear its edge instead of overlapping it. */} diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index f1df96dd92133..ae13f519e02fd 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -54,7 +54,6 @@ function WorkflowMemberRow({ providers, driver, onOpen, - isThreadAvailable, }: { member: RuntimeSubagent; providerInstanceId: ProviderInstanceId; @@ -62,10 +61,9 @@ function WorkflowMemberRow({ providers: ReadonlyArray | undefined; driver: ProviderDriverKind | undefined; onOpen: (threadId: string) => void; - isThreadAvailable: (threadId: string) => boolean; }) { const threadId = member.childThreadId; - const canOpen = threadId !== null && isThreadAvailable(threadId); + const canOpen = threadId !== null; return (
  • @@ -117,7 +115,6 @@ export function ThreadLineageWorkflowRow({ providers, driver, onOpenThread, - isThreadAvailable, onStop, stopping, stopDisabled, @@ -129,7 +126,6 @@ export function ThreadLineageWorkflowRow({ readonly providers: ReadonlyArray | undefined; readonly driver: ProviderDriverKind | undefined; readonly onOpenThread: (threadId: string) => void; - readonly isThreadAvailable: (threadId: string) => boolean; readonly onStop?: (() => void) | undefined; readonly stopping: boolean; readonly stopDisabled: boolean; @@ -227,7 +223,6 @@ export function ThreadLineageWorkflowRow({ providers={providers} driver={driver} onOpen={onOpenThread} - isThreadAvailable={isThreadAvailable} /> ))} @@ -244,7 +239,6 @@ export function ThreadLineageWorkflowRow({ providers={providers} driver={driver} onOpen={onOpenThread} - isThreadAvailable={isThreadAvailable} /> ))} {phases.length === 0 && group.unphasedMembers.length === 0 ? ( diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx new file mode 100644 index 0000000000000..3124cc7b3fa13 --- /dev/null +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -0,0 +1,185 @@ +import { EnvironmentId, NodeId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +const state = vi.hoisted(() => ({ projection: null as unknown, commandSucceeds: false })); + +vi.mock(import("@effect/atom-react"), async (original) => ({ + ...(await original()), + useAtomValue: (() => false) as never, +})); + +vi.mock(import("../../state/entities"), () => ({ + useQuietThreadShell: (() => null) as never, + useThreadProjection: ((ref: unknown) => + ref === null ? null : { projection: state.projection }) as never, + useThreadRelationshipShells: (() => + state.projection === null ? [] : [(state.projection as { thread: unknown }).thread]) as never, + useProjects: (() => []) as never, +})); +vi.mock(import("../../state/environments"), () => ({ + useEnvironment: (() => null) as never, + useEnvironmentProviders: (() => new Map()) as never, +})); +vi.mock(import("../../lib/archivedThreadsState"), () => ({ + useArchivedThreadSnapshots: (() => ({ snapshots: [] })) as never, +})); +vi.mock(import("../../state/use-atom-command"), () => ({ + useAtomCommand: (() => async () => + state.commandSucceeds + ? AsyncResult.success(undefined) + : AsyncResult.failure(Cause.fail(new Error("Connection lost")))) as never, +})); + +import { render } from "../../../test/render"; +import { makeThreadProjectionFixture } from "../../test-fixtures"; +import { ToastProvider } from "../ui/toast"; +import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; + +const environmentId = EnvironmentId.make("environment"); +const threadId = ThreadId.make("fork"); + +function setup() { + state.commandSucceeds = false; + state.projection = { + thread: { + id: threadId, + title: "Fork", + activeProviderThreadId: null, + lineage: { parentThreadId: ThreadId.make("source"), relationshipToParent: "fork" }, + forkedFrom: null, + }, + runs: [{ id: "run", ordinal: 1, status: "completed" }], + subagents: [], + contextTransfers: [], + providerThreads: [], + providerSessions: [{ id: "session", status: "ready" }], + }; +} + +it.each([ + ["merge", "Could not merge back"], + ["disconnect", "Could not disconnect agent session"], +])("reports a rejected %s command and clears the busy state", async (action, title) => { + setup(); + await using screen = await render( + + + , + ); + + if (action === "merge") { + await screen.getByRole("button", { name: "Merge back to source conversation" }).click(); + } else { + await screen.getByRole("button", { name: "More thread actions" }).click(); + await page.getByRole("menuitem", { name: "Disconnect agent session" }).click(); + } + + await expect.element(page.getByText(title, { exact: true })).toBeVisible(); + await expect + .element( + screen.getByRole("button", { + name: action === "merge" ? "Merge back to source conversation" : "More thread actions", + }), + ) + .toBeEnabled(); +}); + +it.each([ + ["merge", "Could not merge back"], + ["disconnect", "Could not disconnect agent session"], +])("clears the previous %s error after a successful retry", async (action, title) => { + setup(); + await using screen = await render( + + + , + { router: "/environment/fork" }, + ); + + const runAction = async () => { + if (action === "merge") { + await screen.getByRole("button", { name: "Merge back to source conversation" }).click(); + } else { + await screen.getByRole("button", { name: "More thread actions" }).click(); + await page.getByRole("menuitem", { name: "Disconnect agent session" }).click(); + } + }; + + await runAction(); + await expect.element(page.getByText(title, { exact: true })).toBeVisible(); + expect(screen.router?.state.location.pathname).toBe("/environment/fork"); + + state.commandSucceeds = true; + await runAction(); + + await expect.element(page.getByText(title, { exact: true })).not.toBeInTheDocument(); + await vi.waitFor(() => { + expect(screen.router?.state.location.pathname).toBe( + action === "merge" ? "/environment/source" : "/environment/fork", + ); + }); + if (action === "disconnect") { + await expect.element(screen.getByRole("button", { name: "More thread actions" })).toBeEnabled(); + } +}); + +it("opens an idle child from Lineage when its shell has not been loaded", async () => { + setup(); + const projection = makeThreadProjectionFixture(); + const childId = ThreadId.make("omitted-child"); + const environmentId = EnvironmentId.make("local"); + state.projection = { + ...projection, + subagents: [ + { + id: NodeId.make("agent"), + threadId: projection.thread.id, + runId: null, + parentNodeId: NodeId.make("parent"), + origin: "app_owned", + createdBy: "agent", + driver: "codex", + providerInstanceId: projection.thread.providerInstanceId, + providerThreadId: null, + childThreadId: childId, + nativeTaskRef: null, + prompt: "Inspect the change", + title: "Idle child", + model: null, + status: "completed", + result: null, + startedAt: null, + completedAt: projection.thread.updatedAt, + updatedAt: projection.thread.updatedAt, + }, + ], + }; + await using screen = await render( + , + ); + await screen.getByRole("button", { name: /Previous agents/ }).click(); + const child = screen.getByRole("button", { name: /Idle child/ }); + await expect.element(child).toBeEnabled(); + await child.click(); + await vi.waitFor(() => + expect(screen.router?.state.location.pathname).toBe("/local/omitted-child"), + ); +}); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 69403392b5ce8..0a9d20cd71e41 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -49,12 +49,13 @@ import { ChevronDownIcon, CornerLeftUpIcon, GitForkIcon, + MessagesSquareIcon, MoreHorizontalIcon, PlusIcon, SquareIcon, UnplugIcon, } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { buildThreadRouteParams } from "../../threadRoutes"; @@ -71,6 +72,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { AgentElapsed } from "./AgentElapsed"; import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon"; import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; +import { openSideChatTab } from "../../lib/sideChatPane"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -349,6 +351,8 @@ function DelegatedFromRow(props: { export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; + readonly showLineage: boolean; + readonly showSideChats: boolean; }) { const ref = scopeThreadRef(props.environmentId, props.threadId); const projection = useThreadProjection(ref)?.projection ?? null; @@ -385,6 +389,8 @@ export function ThreadRelationshipsPanel(props: { orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId), ); const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); + const mergeErrorToastId = useRef(null); + const detachErrorToastId = useRef(null); const [stoppingId, setStoppingId] = useState(null); // Derive graphs after hooks to preserve their compiler cache. const providers = providersByEnvironment.get(props.environmentId); @@ -447,9 +453,12 @@ export function ThreadRelationshipsPanel(props: { related = [], active = [], previous = [], - } = groupBy(relationshipRows, ({ edge }) => { - if (edge.kind !== "subagent" || isParentThreadRelationship(edge, props.threadId)) - return "related"; + sideChats = [], + } = groupBy(relationshipRows, ({ threadId, edge }) => { + const isParent = isParentThreadRelationship(edge, props.threadId); + if (!isParent && graph.nodes.get(threadId)?.thread?.lineage.sideChat === true) + return "sideChats"; + if (edge.kind !== "subagent" || isParent) return "related"; return ["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes( edge.status ?? "", ) @@ -474,16 +483,26 @@ export function ThreadRelationshipsPanel(props: { // A linked environment's agent may have delegated this thread from there. const delegatedFrom = currentThread?.delegatedFrom; - if ( - relationshipRows.length === 0 && - runningCount === 0 && - remoteSubagents.length === 0 && - delegatedFrom === undefined - ) { + const lineageVisible = + props.showLineage && + (related.length > 0 || + active.length > 0 || + previous.length > 0 || + runningCount > 0 || + remoteSubagents.length > 0 || + delegatedFrom !== undefined || + // Disconnect lives in this heading, so a thread with only side chats still shows it. + (canDetach && sideChats.length > 0)); + const sideChatsVisible = props.showSideChats && sideChats.length > 0; + const runningSideChats = sideChats.filter(({ edge }) => edge.status === "running").length; + + if (!lineageVisible && !sideChatsVisible) { return null; } + // Idle children can be omitted from the shell; opening one loads its own detail. const openThread = (threadId: ThreadId) => { + if (openSideChatTab(ref, graph.nodes.get(threadId)?.thread ?? null)) return; void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(props.environmentId, threadId)), @@ -492,6 +511,10 @@ export function ThreadRelationshipsPanel(props: { const merge = async () => { if (!latestMergeBackRun || mergeTargetThreadId === null || busyAction !== null) return; + if (mergeErrorToastId.current !== null) { + toastManager.close(mergeErrorToastId.current); + mergeErrorToastId.current = null; + } setBusyAction("merge"); const result = await mergeBack({ environmentId: props.environmentId, @@ -502,17 +525,34 @@ export function ThreadRelationshipsPanel(props: { }, }); setBusyAction(null); - if (result._tag === "Success") openThread(mergeTargetThreadId); + if (result._tag === "Failure") { + mergeErrorToastId.current = toastManager.add({ + type: "error", + title: "Could not merge back", + }); + return; + } + openThread(mergeTargetThreadId); }; const detach = async () => { if (!canDetach || busyAction !== null) return; + if (detachErrorToastId.current !== null) { + toastManager.close(detachErrorToastId.current); + detachErrorToastId.current = null; + } setBusyAction("detach"); - await stopSession({ + const result = await stopSession({ environmentId: props.environmentId, input: { threadId: props.threadId }, }); setBusyAction(null); + if (result._tag === "Failure") { + detachErrorToastId.current = toastManager.add({ + type: "error", + title: "Could not disconnect agent session", + }); + } }; const stopSubagent = async ( @@ -549,327 +589,331 @@ export function ThreadRelationshipsPanel(props: { ? null : (graph.nodes.get(mergeTargetThreadId)?.thread?.title ?? null); - return ( - 0 ? `Lineage · ${runningCount} running` : "Lineage"} - // A lone "Previous agents" row names itself; the heading returns for status or actions. - showHeading={ - runningCount > 0 || - canDetach || - related.length > 0 || - active.length > 0 || - delegatedFrom !== undefined - } - data-thread-relationships-panel - actions={ - canDetach ? ( - - - } - > - - - - void detach()}> - - Disconnect agent session - - - - ) : null - } - > - {delegatedFrom === undefined ? null : ( - {}}> - - - )} - {groups.map((group) => ( - - {(visibleRows) => - visibleRows.map(({ threadId, edge }) => { - const node = graph.nodes.get(threadId); - const isSubagent = edge.kind === "subagent"; - const isMergeTarget = threadId === mergeTargetThreadId; - const isParent = isParentThreadRelationship(edge, props.threadId); - const rowStatus = - (isParent && threadId === parentRef?.threadId - ? (node?.thread?.activityRunStatus ?? parentNativeStatus) - : undefined) ?? threadRelationshipRowStatus(graph, { threadId, edge }); - const RelationshipIcon = isParent - ? CornerLeftUpIcon - : isSubagent - ? BotIcon - : GitForkIcon; - const relationship = relationshipLabel(edge, props.threadId); - const projectedAgent = - isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; - const workflowGroup = - projectedAgent === undefined - ? undefined - : workflowGroupsById.get(projectedAgent.id); - const agent = workflowGroup - ? projectedAgent - : currentSubagent(projectedAgent, node?.thread); - const failed = rowStatus === "failed" || rowStatus === "error"; - const canStop = - agent && - (agent.origin === "app_owned" || - (agent.origin === "provider_native" && - agent.driver === "claudeAgent" && - agent.runId != null && - agent.nativeTaskRef?.strength === "strong" && - agent.nativeTaskRef.nativeId !== null && - ["pending", "running", "waiting"].includes(agent.nativeStatus))) && - agent.startedAt && - ["pending", "running", "waiting"].includes(agent.status); - const threadTitle = relationshipThreadTitle({ - title: - workflowGroup?.workflow.workflowName ?? - node?.thread?.title ?? - agent?.title ?? - threadId, - isSubagent, - }); - const provider = providers?.find( - (entry) => - entry.instanceId === - (agent?.providerInstanceId ?? node?.thread?.providerInstanceId), - ); - const providerDriver = agent?.driver ?? provider?.driver; - const canStopWorkflow = - canOperateWorkflow && - workflowGroup?.workflow.status === "running" && - agent?.origin === "provider_native" && - providerDriver === "claudeAgent"; - const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop) - ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" - : ""; - const project = projects.find((project) => project.id === node?.thread?.projectId); - const relationshipHint = node?.missing - ? "This related thread is unavailable" - : `Open ${relationship.toLowerCase()} in this chat`; - const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup; - const relationshipTooltip = agent ? ( - } - status={agent.status} - result={agent.result} - progress={agent.progress} - parentThread={currentThread ?? undefined} - childThread={node?.thread ?? undefined} - parentProject={currentProject} - childProject={project} - /> - ) : ( - relationshipHint - ); - const relationshipContent = ( - <> - { + const node = graph.nodes.get(threadId); + const isSubagent = edge.kind === "subagent"; + const isMergeTarget = threadId === mergeTargetThreadId; + const isParent = isParentThreadRelationship(edge, props.threadId); + const rowStatus = + (isParent && threadId === parentRef?.threadId + ? (node?.thread?.activityRunStatus ?? parentNativeStatus) + : undefined) ?? threadRelationshipRowStatus(graph, { threadId, edge }); + const RelationshipIcon = isParent + ? CornerLeftUpIcon + : isSubagent + ? BotIcon + : node?.thread?.lineage.sideChat === true + ? MessagesSquareIcon + : GitForkIcon; + const relationship = + node?.thread?.lineage.sideChat === true && !isParent + ? "Side chat" + : relationshipLabel(edge, props.threadId); + const projectedAgent = isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; + const workflowGroup = + projectedAgent === undefined ? undefined : workflowGroupsById.get(projectedAgent.id); + const agent = workflowGroup ? projectedAgent : currentSubagent(projectedAgent, node?.thread); + const failed = rowStatus === "failed" || rowStatus === "error"; + const canStop = + agent && + (agent.origin === "app_owned" || + (agent.origin === "provider_native" && + agent.driver === "claudeAgent" && + agent.runId != null && + agent.nativeTaskRef?.strength === "strong" && + agent.nativeTaskRef.nativeId !== null && + ["pending", "running", "waiting"].includes(agent.nativeStatus))) && + agent.startedAt && + ["pending", "running", "waiting"].includes(agent.status); + const threadTitle = relationshipThreadTitle({ + title: + workflowGroup?.workflow.workflowName ?? node?.thread?.title ?? agent?.title ?? threadId, + isSubagent, + }); + const provider = providers?.find( + (entry) => + entry.instanceId === (agent?.providerInstanceId ?? node?.thread?.providerInstanceId), + ); + const providerDriver = agent?.driver ?? provider?.driver; + const canStopWorkflow = + canOperateWorkflow && + workflowGroup?.workflow.status === "running" && + agent?.origin === "provider_native" && + providerDriver === "claudeAgent"; + const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop) + ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" + : ""; + const project = projects.find((project) => project.id === node?.thread?.projectId); + const relationshipHint = `Open ${relationship.toLowerCase()} in this chat`; + const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup; + const relationshipTooltip = agent ? ( + } + status={agent.status} + result={agent.result} + progress={agent.progress} + parentThread={currentThread ?? undefined} + childThread={node?.thread ?? undefined} + parentProject={currentProject} + childProject={project} + /> + ) : ( + relationshipHint + ); + const relationshipContent = ( + <> + + + + {threadTitle} + + + {agent ? null : ( + + )} + {agent && !failed && (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? ( + + + + ) : !isMergeTarget ? ( + + {threadRelationshipStatusLabel(rowStatus)} + + ) : null} + + ); + const relationshipLink = ( + + openThread(threadId)} + /> + } + > + {relationshipContent} + + {relationshipTooltip} + + ); + if (workflowGroup && agent) { + return ( + openThread(memberThreadId as ThreadId)} + onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined} + stopping={stoppingId === agent.id} + stopDisabled={stoppingId !== null} + header={relationshipLink} + /> + ); + } + return ( +
  • + {isMergeTarget ? ( +
    + + openThread(threadId)} /> - - - {threadTitle} - - - {agent ? null : ( - - )} - {agent && - !failed && - (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? ( - - - - ) : !isMergeTarget ? ( - - {threadRelationshipStatusLabel(rowStatus)} - - ) : null} - - ); - const relationshipLink = ( - - openThread(threadId)} - /> + } + > + {relationshipContent} + + {relationshipTooltip} + + + + {threadRelationshipStatusLabel(rowStatus)} + +
    + ) : ( + relationshipLink + )} + {canStop && agent ? ( +
    + + void stopSubagent(threadId, agent)} /> - ); - } - return ( -
  • - {isMergeTarget ? ( -
    - - openThread(threadId)} - /> - } - > - {relationshipContent} - - {relationshipTooltip} - -
    - ) : ( - relationshipLink - )} - {canStop && agent ? ( -
    - - void stopSubagent(threadId, agent)} - /> - } - > - {stoppingId === threadId ? ( - - ) : ( - - )} - - Stop subagent - -
    - ) : null} -
  • - ); - }) + } + > + {stoppingId === threadId ? ( + + ) : ( + + )} + + Stop subagent +
    +
    + ) : null} + + ); + }; + + return ( + <> + {lineageVisible ? ( + 0 ? `Lineage · ${runningCount} running` : "Lineage"} + // A lone "Previous agents" row names itself; the heading returns for status or actions. + showHeading={ + runningCount > 0 || + canDetach || + related.length > 0 || + active.length > 0 || + delegatedFrom !== undefined } - - ))} - {remoteSubagents.length > 0 ? ( - {}}> - {remoteSubagents.map((agent) => ( - entry.instanceId === agent.providerInstanceId)} - /> + data-thread-relationships-panel + actions={ + canDetach ? ( + + + } + > + + + + void detach()}> + + Disconnect agent session + + + + ) : null + } + > + {delegatedFrom === undefined ? null : ( + {}}> + + + )} + {groups.map((group) => ( + + {(visibleRows) => visibleRows.map(renderRelationshipRow)} + ))} - + {remoteSubagents.length > 0 ? ( + {}}> + {remoteSubagents.map((agent) => ( + entry.instanceId === agent.providerInstanceId)} + /> + ))} + + ) : null} + + ) : null} + {sideChatsVisible ? ( + 0 ? `Side chats · ${runningSideChats} running` : "Side chats"} + data-thread-side-chats-panel + > + + {(visibleRows) => visibleRows.map(renderRelationshipRow)} + + ) : null} - + ); } diff --git a/apps/web/src/components/chat/ThreadServersPanel.tsx b/apps/web/src/components/chat/ThreadServersPanel.tsx index 353f6032b6f87..f1c8e1a1808f1 100644 --- a/apps/web/src/components/chat/ThreadServersPanel.tsx +++ b/apps/web/src/components/chat/ThreadServersPanel.tsx @@ -30,7 +30,7 @@ import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./threadDetailsPanelStyles"; const serverKey = (server: DiscoveredLocalServer) => `${server.pid}:${server.port}`; -/** Web servers the thread's terminals and agent started. Hidden while none run. */ +/** Local servers the thread's terminals and agent started. Hidden while none run. */ export function ThreadServersPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts b/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts index 9738e29d7ccaa..2bb6d0470fa18 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts +++ b/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts @@ -1,7 +1,11 @@ -import { type ChatImageAttachment } from "../../types"; +import { type ChatFileAttachment, type ChatImageAttachment } from "../../types"; import type { ComposerContextRecord, KnownComposerContextRecord } from "@t3tools/contracts"; import { asKnownContextRecord } from "~/lib/composerContextRecords"; +export function userMessageFileLabel(file: Pick) { + return file.source?._tag === "pasted-text" ? "Pasted text" : file.name; +} + export function resolvePreviewAnnotationImage(input: { record: Extract; recordsById: ReadonlyMap; diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index aee30e27d51ec..e73f5d1d1e9a7 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -62,7 +62,10 @@ import { resolveUserMessageContext, selectedMessageContextFragment, } from "~/lib/composerContextRecords"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + collectComposerContextReferences, + formatComposerContextReference, +} from "@t3tools/shared/composerContextReferences"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, encodeComposerContextClipboardHtml, @@ -71,6 +74,7 @@ import { import { chatMarkdownClipboardPayload } from "../../markdown-clipboard"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; import { cn } from "~/lib/utils"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock"; import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext"; @@ -80,7 +84,10 @@ import { TimelineRowTimestamp, } from "./TimelineRowParts"; import { UserMessageContextReferenceChip } from "./UserMessageContextChips"; -import { resolvePreviewAnnotationImage } from "./TimelineUserMessageRow.logic"; +import { + resolvePreviewAnnotationImage, + userMessageFileLabel, +} from "./TimelineUserMessageRow.logic"; // Drawn inside the media so dark screenshots keep an edge on the dark background. const MEDIA_EDGE_CLASS_NAME = @@ -97,6 +104,8 @@ function UserFileCard({ readonly copyMarkdown: string | undefined; }) { const ctx = use(TimelineRowCtx); + const pastedText = file.source?._tag === "pasted-text"; + const label = userMessageFileLabel(file); const extension = /\.([a-z0-9]{1,8})$/i.exec(file.name)?.[1]?.toUpperCase() ?? "File"; const identity = ( <> @@ -105,10 +114,17 @@ function UserFileCard({ - {file.name} + {label} - {extension} · {formatBytes(file.sizeBytes)} + {file.downloadable === false ? ( + "Sending…" + ) : ( + <> + {!pastedText && `${extension} · `} + {formatBytes(file.sizeBytes)} + + )} @@ -119,12 +135,17 @@ function UserFileCard({ className="flex w-64 max-w-full items-center gap-1 rounded-2xl bg-message py-2 ps-2 pe-1 text-message-foreground" > {file.downloadable === false ? ( -
    {identity}
    + <> +
    {identity}
    + + ) : ( <> @@ -681,14 +743,72 @@ function UserTimelineRow({ row }: { row: Extract ))} {userVideos.map((file) => ( - + ))}
    ) : null; + // Touch shows actions all the time, and narrow lanes have no room beside the + // bubble, so both keep the actions on their own line. + const floatActions = row.groupedWithNext === true && !coarsePointer; + const messageActions = ( +
    + {folded && senderThreadId ? ( + ctx.onOpenThread(senderThreadId)} tone="muted"> + Open thread + + ) : null} + + }> + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} + + + {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} + + +
    + {typeof revertTurnCount === "number" && ( + + )} + {copyText && ( + + )} + {promptLink !== null && ( + + )} +
    +
    + ); return (
    {userMessage.attribution === "t3code" ? ( @@ -720,12 +840,13 @@ function UserTimelineRow({ row }: { row: Extract @@ -760,11 +881,12 @@ function UserTimelineRow({ row }: { row: Extract {!mediaAboveText && mediaBlock} {userAudio.length > 0 ? ( -
    +
    {userAudio.map((file) => ( 0 || unknownAttachments.length > 0) ? (
    {unchippedFiles.map((file) => { + const pastedText = file.source?._tag === "pasted-text"; + const label = userMessageFileLabel(file); const fileIdentity = ( <> - {file.name} + + {label} + {file.downloadable === false || pastedText ? ( + + {file.downloadable === false ? "Sending…" : formatBytes(file.sizeBytes)} + + ) : null} + ); if (file.downloadable !== false) { @@ -810,7 +941,7 @@ function UserTimelineRow({ row }: { row: Extract
    ); @@ -854,7 +985,7 @@ function UserTimelineRow({ row }: { row: Extract ) : null} - {fileCardsOnly ? null : ( + {fileCardsOnly || audioOnly ? null : (
    ) : null} + {floatActions ? ( + + ) : null}
    {!folded || expanded ? ( -
    -
    - {folded && senderThreadId ? ( - ctx.onOpenThread(senderThreadId)} tone="muted"> - Open thread - - ) : null} - - }> - {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} - - - {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} - - -
    - {typeof revertTurnCount === "number" && ( - - )} - {resolvedContext.text && ( - - )} - {promptLink !== null && ( - - )} -
    -
    +
    + {messageActions}
    ) : null}
    ); } +const ACTIONS_REVEAL_CLASS_NAME = + "items-center text-xs tabular-nums opacity-0 transition-opacity duration-200 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover:opacity-100"; + function UserMessageIntentMarker({ intent, }: { diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts b/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts index 12b9a6f197346..4a067b7de1ce5 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts @@ -1,3 +1,4 @@ +import type { PluggableList } from "unified"; import type { Root, RootContent } from "mdast"; import { resolveWorkEntryToolPresentation, @@ -237,3 +238,6 @@ export function remarkThoughtPreview(fallback: string) { : [{ type: "text", value: fallback }]; }; } + +export const THOUGHT_PREVIEW_PLUGINS: PluggableList = [[remarkThoughtPreview, "Thought"]]; +export const THINKING_PREVIEW_PLUGINS: PluggableList = [[remarkThoughtPreview, "Thinking"]]; diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index a8d5e3e68de77..76f2f604ba85f 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -64,7 +64,8 @@ import { workEntryRawCommand, buildToolCallExpandedBody, workEntryIconName, - remarkThoughtPreview, + THINKING_PREVIEW_PLUGINS, + THOUGHT_PREVIEW_PLUGINS, } from "./TimelineWorkEntryRow.logic"; export function useThreadReadTarget(entry: TimelineWorkEntry, environmentId: EnvironmentId) { @@ -160,6 +161,7 @@ export const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEn ) : threadLabel ? ( } @@ -558,7 +559,7 @@ export function LiveWorkEntryTimelineRow({ text={row.entry.detail ?? label} cwd={ctx.markdownCwd} parseRawHtml={false} - extraRemarkPlugins={[[remarkThoughtPreview, row.active ? "Thinking" : "Thought"]]} + extraRemarkPlugins={row.active ? THINKING_PREVIEW_PLUGINS : THOUGHT_PREVIEW_PLUGINS} /> ) : threadLabel ? ( ; @@ -190,12 +186,12 @@ function getSelectedTraits( }; } -function getTraitsSectionVisibility(input: { +export function getTraitsSectionVisibility(input: { provider: ProviderDriverKind; models: ReadonlyArray; model: string | null | undefined; promptUltrathink?: PromptUltrathink | undefined; - modelOptions: ProviderOptions | null | undefined; + modelOptions?: ProviderOptions | null | undefined; allowPromptInjectedEffort?: boolean; planModeEnabled: boolean; }) { @@ -261,7 +257,8 @@ export function getReasoningTraitStep(input: { const descriptor = selected.selectDescriptors.find((entry) => REASONING_SELECT_DESCRIPTOR_IDS.has(entry.id), ); - if (!descriptor || descriptor.options.length < 2) return null; + const choices = descriptor ? selectableProviderOptionChoices(descriptor) : []; + if (!descriptor || choices.length < 2) return null; const isPrimary = descriptor.id === selected.primarySelectDescriptor?.id; const current = isPrimary && selected.ultrathinkPromptControlled @@ -271,41 +268,20 @@ export function getReasoningTraitStep(input: { { instanceId: input.instanceId, model: input.model, options: input.modelOptions ?? [] }, input.reportedModelSelection, ); + // Walk the full catalog order so a typed Ultrathink keeps its place, then land on a pickable level. const ordered = input.direction > 0 ? descriptor.options : descriptor.options.toReversed(); const position = [current, getDescriptorStringValue(descriptor)] .map((value) => ordered.findIndex((option) => option.id === value)) .find((index) => index >= 0) ?? -1; - for (const option of [ - ...ordered.slice(position + 1), - ...ordered.slice(0, Math.max(position, 0)), - ]) { - const change = getTraitSelectChange(selected, descriptor, input.prompt, option.id); - if (!change) return null; - if ("modelOptions" in change) { - return change.modelOptions - ? { prompt: change.prompt, modelOptions: change.modelOptions } - : null; - } - // A slash command refuses the Ultrathink prefix, so skip that level. - if (change.prompt.startsWith(ULTRATHINK_PROMPT_PREFIX)) return change; - } - return null; -} - -export function shouldRenderTraitsControls(input: { - provider: ProviderDriverKind; - models: ReadonlyArray; - model: string | null | undefined; - promptUltrathink?: PromptUltrathink | undefined; - modelOptions: ProviderOptions | null | undefined; - allowPromptInjectedEffort?: boolean; - planModeEnabled: boolean; -}): boolean { - return getTraitsSectionVisibility(input).hasAnyControls; + const next = [...ordered.slice(position + 1), ...ordered.slice(0, Math.max(position, 0))].find( + (option) => choices.includes(option), + ); + const change = next && getTraitSelectChange(selected, descriptor, input.prompt, next.id); + return change?.modelOptions ? { prompt: change.prompt, modelOptions: change.modelOptions } : null; } -interface TraitsMenuContentProps { +export interface TraitsMenuContentProps { provider: ProviderDriverKind; instanceId?: ProviderInstanceId; models: ReadonlyArray; @@ -318,18 +294,12 @@ interface TraitsMenuContentProps { allowPromptInjectedEffort?: boolean; planModeEnabled: boolean; triggerClassName?: string; - isComposerOwned?: boolean; - /** Composer chips name the effort level unless the user picked bars. */ - effortDisplay?: EffortDisplay; - /** Extra section shown below the traits in the picker popup (Permissions). */ - extraMenuContent?: ReactNode; - /** This trigger also answers composer.mode (while the permissions chip is hidden). */ - extraMenuOwnsModeShortcut?: boolean; - /** Extra tooltip text naming what else the menu holds. */ - extraMenuHint?: string; } -export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ +export type TraitsSelectionInput = TraitsMenuContentProps & TraitsPersistence; + +/** The model's traits with their shown values, and writers that persist a change. */ +export function useTraitsSelection({ provider, instanceId, models, @@ -340,8 +310,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ reportedModelSelection, allowPromptInjectedEffort = true, planModeEnabled, + triggerClassName: _triggerClassName, ...persistence -}: TraitsMenuContentProps & TraitsPersistence) { +}: TraitsSelectionInput) { const modelSelection = instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); @@ -369,32 +340,51 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ allowPromptInjectedEffort, planModeEnabled, }); + return { + ...selected, + /** The option a select shows, where an Ultrathink prompt owns the primary select. */ + selectValue: (descriptor: Extract) => + selected.ultrathinkPromptControlled && descriptor.id === selected.primarySelectDescriptor?.id + ? "ultrathink" + : (getDescriptorStringValue(descriptor, modelSelection, reportedModelSelection) ?? ""), + currentLabel: (descriptor: ProviderOptionDescriptor) => + getProviderOptionCurrentLabel(descriptor, modelSelection, reportedModelSelection), + changeSelect: ( + descriptor: Extract, + value: string, + ) => { + // Whether a pick applies, and the options it writes, do not depend on the prompt text. + const change = getTraitSelectChange(selected, descriptor, "", value); + if (!change) return; + onPromptEdit?.( + (prompt) => getTraitSelectChange(selected, descriptor, prompt, value)?.prompt ?? prompt, + ); + updateModelOptions(change.modelOptions); + }, + changeBoolean: (descriptorId: string, value: boolean) => { + updateModelOptions( + buildProviderOptionSelectionsFromDescriptors( + replaceDescriptorCurrentValue(selected.descriptors, descriptorId, value), + ), + ); + }, + }; +} + +export const ULTRATHINK_IN_BODY_NOTE = + 'Your prompt contains "ultrathink" in the text. Remove it to change this option.'; + +const TraitsMenuContent = memo(function TraitsMenuContentImpl(props: TraitsSelectionInput) { + const traits = useTraitsSelection(props); const { descriptors, selectDescriptors, booleanDescriptors, primarySelectDescriptor, - ultrathinkPromptControlled, ultrathinkInBodyText, hasAnyControls, modelIsUnavailable, - } = selected; - const updateDescriptors = (nextDescriptors: ReadonlyArray) => { - updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); - }; - - const handleSelectChange = ( - descriptor: Extract, - value: string, - ) => { - // Whether a pick applies, and the options it writes, do not depend on the prompt text. - const change = getTraitSelectChange(selected, descriptor, "", value); - if (!change) return; - onPromptEdit?.( - (prompt) => getTraitSelectChange(selected, descriptor, prompt, value)?.prompt ?? prompt, - ); - if ("modelOptions" in change) updateModelOptions(change.modelOptions); - }; + } = traits; if (!hasAnyControls) { return null; @@ -404,11 +394,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ return ( <> {descriptors.map((descriptor, index) => { - const value = getProviderOptionCurrentLabel( - descriptor, - modelSelection, - reportedModelSelection, - ); + const value = traits.currentLabel(descriptor); if (!value) return null; return (
    @@ -428,200 +414,100 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ return ( <> - {selectDescriptors.map((descriptor, index) => { - const selectedValue = - ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id - ? "ultrathink" - : (getDescriptorStringValue(descriptor, modelSelection, reportedModelSelection) ?? ""); - - return ( -
    - {index > 0 ? : null} - -
    - {descriptor.label} -
    - {ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id ? ( -
    - Your prompt contains "ultrathink" in the text. Remove it to change this - option. -
    - ) : null} - handleSelectChange(descriptor, value)} - > - {selectableProviderOptionChoices(descriptor).map((option) => ( - - - - {option.label} - {option.isDefault ? : null} - - {option.description ? ( - - {option.description} - - ) : null} - - - ))} - -
    -
    - ); - })} - {booleanDescriptors.map((descriptor, index) => { - const selectedValue = descriptor.currentValue === true ? "on" : "off"; - - return ( -
    - {index > 0 || selectDescriptors.length > 0 ? : null} - -
    - {descriptor.label} + {selectDescriptors.map((descriptor, index) => ( +
    + {index > 0 ? : null} + +
    + {descriptor.label} +
    + {ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id ? ( +
    + {ULTRATHINK_IN_BODY_NOTE}
    - { - updateDescriptors( - replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"), - ); - }} - > - {(["on", "off"] as const).map((value) => ( - - - {value === "on" ? "On" : "Off"} + ) : null} + traits.changeSelect(descriptor, value)} + > + {selectableProviderOptionChoices(descriptor).map((option) => ( + + + + {option.label} + {option.isDefault ? : null} - - ))} - -
    -
    - ); - })} + {option.description ? ( + + {option.description} + + ) : null} + + + ))} + + +
    + ))} + {booleanDescriptors.map((descriptor, index) => ( +
    + {index > 0 || selectDescriptors.length > 0 ? : null} + +
    + {descriptor.label} +
    + traits.changeBoolean(descriptor.id, value === "on")} + > + {(["on", "off"] as const).map((value) => ( + + + {value === "on" ? "On" : "Off"} + + + ))} + +
    +
    + ))} ); }); -/** One bolt for Fast. Ultrafast adds an outlined echo behind it, cut away by the surface color. */ -function FastBolt(props: { ultra: boolean }) { - if (!props.ultra) { - return ( -