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/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 507e013bf1c2a..6877f5c831a08 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -215,7 +215,7 @@ jobs: git config user.name "${APP_SLUG}[bot]" git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" git add apps/mobile/app.config.ts - git commit \ + git commit --no-verify \ -m "chore(mobile): bump app version to $RELEASE_VERSION" \ -m "Co-authored-by: codex " git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d89b7c7abe7a..c36d51bdadb1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1388,7 +1388,7 @@ jobs: git config user.email "${{ steps.app_bot.outputs.email }}" git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml - git commit -m "chore(release): prepare $RELEASE_TAG" + git commit --no-verify -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main announce_discord: diff --git a/AGENTS.md b/AGENTS.md index d9699e8f1a3ff..c942d3024f289 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/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 9c8f49e4f7cb5..184843d480249 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(() => ({ @@ -59,7 +67,7 @@ const layerDesktopClerk = ( listeners = new Map void>(), ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ - stateDir: "/tmp/t3-state", + stateDir: clerkStateDir, isDevelopment, appDataDirectory: "/tmp/app-data", platform, @@ -93,7 +101,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), @@ -160,6 +168,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"); @@ -169,7 +169,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, @@ -180,7 +185,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 f05efcbd45b0a..9f1138ca55b0f 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, @@ -476,6 +483,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", () => @@ -899,6 +945,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(); @@ -977,6 +1056,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 a463fe53b8a60..e81afaf62134d 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 5b3be91b44bcc..28092207c22e5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -177,7 +177,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 31956f6574340..b82a4468f0ba6 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1012,6 +1012,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 c6dc31ca00ad2..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,23 +139,54 @@ 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; readonly actionsSource?: MediaActionsSource; + readonly borderRadius?: number; } 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 = ( {playbackUri ? ( ); + 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/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 058dea6ab81a3..efc665955656d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -58,7 +58,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 { useMessageDraftCounts } from "../../state/messageDrafts"; diff --git a/apps/mobile/src/features/home/use-thread-list-filter-facets.ts b/apps/mobile/src/features/home/use-thread-list-filter-facets.ts index b1629a8f7e562..cc7f811873ff9 100644 --- a/apps/mobile/src/features/home/use-thread-list-filter-facets.ts +++ b/apps/mobile/src/features/home/use-thread-list-filter-facets.ts @@ -2,8 +2,9 @@ import { useAtomValue } from "@effect/atom-react"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { useMemo } from "react"; +import { useThreadListNow } from "../threads/use-thread-list-now"; import { useThreadSearch } from "../../state/queries"; -import { threadListEnvironmentsAtom } from "../../state/server"; +import { threadListEnvironmentsAtom } from "../../state/presentation"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; import { buildThreadListFilterFacets } from "./thread-list-filter-facets"; @@ -48,6 +49,7 @@ export function useThreadListFilterFacets(input: FacetInput) { pages, searchQuery, } = input; + const now = useThreadListNow(threads); return useMemo( () => buildThreadListFilterFacets({ @@ -62,7 +64,7 @@ export function useThreadListFilterFacets(input: FacetInput) { searchQuery, matchedThreadKeys, classification: { - now: new Date().toISOString(), + now, settlementEnvironmentIds: listEnvironments.settlementEnvironmentIds, snoozeEnvironmentIds: listEnvironments.snoozeEnvironmentIds, hidingEnvironmentIds: listEnvironments.hidingEnvironmentIds, @@ -72,6 +74,7 @@ export function useThreadListFilterFacets(input: FacetInput) { }, }), [ + now, environmentId, listEnvironments, matchedThreadKeys, diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 640d764dbea34..6f599832becc6 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, @@ -64,6 +65,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", })); @@ -73,30 +77,32 @@ vi.mock("../../state/atom-registry", () => ({ state.dropBusy = value; }, get: (atom: string) => - atom === "thread-drop-busy" - ? state.dropBusy - : atom === "project:web" - ? { id: "web", title: "Web", workspaceRoot: "/repos/web" } - : 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 === "project:web" + ? { id: "web", title: "Web", workspaceRoot: "/repos/web" } + : 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/projects", () => ({ @@ -141,6 +147,7 @@ vi.mock("../../state/threads", () => ({ "pin", "unpin", "reorderPin", + "reorderActive", "updateMetadata", "moveToProject", ].map((action) => [ @@ -191,6 +198,7 @@ const mutationCases = [ ] as const; beforeEach(() => { + state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; state.failedAction = null; state.archivedRefreshes = []; state.pendingOrder = null; @@ -320,6 +328,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 e6db241e37d92..940e7f265e3fb 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -16,6 +16,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 { environmentProjects } from "../../state/projects"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; @@ -795,6 +796,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", @@ -814,7 +819,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 1e7e984de79a7..704856a8c6c14 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, @@ -420,10 +420,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/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 655251f9f01f2..2d9fe04600250 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"; @@ -33,7 +35,6 @@ import { type PullRequestFilterMenu, } from "./PullRequestListFilters"; import { usePullRequestList, usePullRequestListHeads } from "./usePullRequestList"; -import { PullRequestCardRow } from "./PullRequestControls"; import { usePullRequestListControls } from "./usePullRequestListControls"; import { usePullRequestListActions } from "./usePullRequestListActions"; import { @@ -59,26 +60,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} + + + ); } @@ -324,13 +337,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 024089f0e0dde..1bd0121904304 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(); @@ -140,6 +146,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", @@ -170,6 +181,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 00b3f06c9ec3c..a584511ce6de3 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -90,6 +90,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..27639044e758c 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -747,7 +747,7 @@ export const BranchToolbar = memo(function BranchToolbar({ className={cn("gap-1 text-xs font-normal text-foreground/70 dark:text-muted-foreground")} > {showGitControls ? ( -
+
@@ -817,7 +817,7 @@ export const BranchToolbar = memo(function BranchToolbar({ ({ workspaceSearch: vi.fn(), filesystemMetadata: vi.fn(), })); +const parsedSources = vi.hoisted(() => [] as string[]); +vi.mock(import("mdast-util-from-markdown"), async (original) => { + const actual = await original(); + return { + ...actual, + fromMarkdown: ((source: string, ...rest: never[]) => { + parsedSources.push(String(source)); + return (actual.fromMarkdown as (...args: unknown[]) => unknown)(source, ...rest); + }) as typeof actual.fromMarkdown, + }; +}); + vi.mock(import("../state/use-atom-query-runner"), async (original) => ({ ...(await original()), useAtomQueryRunner: ((family: unknown) => @@ -99,6 +111,7 @@ it("keeps compact audio playback loaded across signed URL renewals", async () => }); function setup() { + parsedSources.length = 0; settingsOverrides.mathRenderingEnabled = false; renderDiagram.mockReset(); workspaceSearch.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); @@ -139,6 +152,34 @@ it("loads a host image through the page's own route when there is no environment ); }); +it("reuses the parse of a streaming reply's finished blocks when it remounts", async () => { + setup(); + const message = ( + + ); + { + await using _first = await render(message); + } + await using screen = await render(message); + await expect.element(screen.getByText("Still growing")).toBeVisible(); + expect(parsedSources.filter((source) => source.includes("Finished block"))).toHaveLength(1); +}); + +it("reuses the parse of a remounted message that has stable extra plugins", async () => { + setup(); + const parsed = vi.fn(); + const countingPlugins = [() => () => void parsed()]; + const message = ( + + ); + { + await using _first = await render(message); + } + await using screen = await render(message); + await expect.element(screen.getByText("Cached text")).toBeVisible(); + expect(parsed).toHaveBeenCalledTimes(1); +}); + describe("ChatMarkdown favicon privacy", () => { it("draws link icons in-app and never requests a remote favicon", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.githubLinks.browser.test.tsx b/apps/web/src/components/ChatMarkdown.githubLinks.browser.test.tsx index 9490024be283f..4cf88968b9299 100644 --- a/apps/web/src/components/ChatMarkdown.githubLinks.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.githubLinks.browser.test.tsx @@ -59,7 +59,7 @@ vi.mock(import("../state/pullRequests"), async (original) => { ...actual, pullRequestEnvironment: { ...actual.pullRequestEnvironment, - detail: (() => "pull-request-detail") as never, + previewCard: (() => "pull-request-detail") as never, }, }; }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 7642cee08bdd1..63de606c883b2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -93,8 +93,8 @@ import type { ExtraProps as ReactMarkdownExtraProps, Options as ReactMarkdownOptions, } from "react-markdown"; -import ReactMarkdown from "react-markdown"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; +import type { Pluggable, PluggableList } from "unified"; import { defaultUrlTransform } from "react-markdown"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; @@ -208,6 +208,7 @@ import { import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBlock"; +import { createMarkdownRenderer } from "./chat/markdownRenderer"; import { splitMarkdownBlocks } from "./chat/markdownStreamBlocks"; import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock"; import { MarkdownTable } from "./chat/MarkdownTable"; @@ -239,6 +240,7 @@ import { discordEnvironment } from "../state/discord"; import { granolaEnvironment } from "../state/granola"; import { MarkdownFileLink } from "./chat/MarkdownFileLink"; import { canUseMarkdownFileShellActions } from "./chat/MarkdownFileLink.logic"; +import { rehypeChipPunctuation } from "./chat/markdownChipPunctuation"; import { handleMarkdownFragmentClick, rehypeHeadingIds } from "./chat/markdownFragments"; import { rehypeTextDirection } from "./chat/markdownTextDirection"; import { hastHasText, hastPlainTextDeep, plainHastText } from "./chat/markdownHast.logic"; @@ -273,6 +275,7 @@ interface ChatMarkdownProps { imageBaseDir?: string | undefined; workspaceMutationId?: string | null | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + /** Keep one list per configuration (a module constant): it keys the shared parse cache. */ extraRemarkPlugins?: NonNullable; renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; /** Loads media a source control host keeps behind a credential through the server, which holds @@ -430,13 +433,28 @@ const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ ...CHAT_MARKDOWN_REHYPE_PLUGINS, rehypeHeadingIds, rehypeTextDirection, + rehypeChipPunctuation, ] satisfies NonNullable; +const CHAT_MARKDOWN_THEMED_REHYPE_PLUGINS = { + light: [...CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS, rehypeResolvePictureSources("light")], + dark: [...CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS, rehypeResolvePictureSources("dark")], +} satisfies Record<"light" | "dark", NonNullable>; + const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [ rehypeHeadingIds, rehypeTextDirection, + rehypeChipPunctuation, ] satisfies NonNullable; +function markdownUrlTransform(href: string) { + if (parseAssistantCitationHref(href)) return href; + if (parseComposerContextHref(href)) return href; + if (parseThreadLinkHref(href)) return href; + if (isWindowsDrivePathHref(href)) return href; + return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); +} + const NO_MARKDOWN_EDITORS: ReadonlyArray = []; type MarkdownServerConfigFields = ReturnType; @@ -719,13 +737,6 @@ function useChatMarkdownState({ }); }; const diffThemeName = resolveDiffThemeName(resolvedTheme); - const markdownUrlTransform = (href: string) => { - if (parseAssistantCitationHref(href)) return href; - if (parseComposerContextHref(href)) return href; - if (parseThreadLinkHref(href)) return href; - if (isWindowsDrivePathHref(href)) return href; - return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); - }; const handleCopy = (event: ReactClipboardEvent) => { const selection = window.getSelection(); if (!selection || selection.isCollapsed || !event.clipboardData) return; @@ -1015,7 +1026,6 @@ function useChatMarkdownState({ componentState, handleCopy, markdownRef, - markdownUrlTransform, localMediaPreview, closeLocalMediaPreview, }; @@ -1643,7 +1653,8 @@ const CHAT_MARKDOWN_COMPONENTS = { chip ) : ( - {children} {chip} + {children} + {chip} ); }, @@ -1961,29 +1972,74 @@ const CHAT_MARKDOWN_COMPONENTS = { }, } satisfies Components; +const REMARK_MATH: Pluggable = [remarkMath, { singleDollarTextMath: true }]; +// Shared lists, so every message with the same options shares a processor and its cache. +const CHAT_MARKDOWN_REMARK_PLUGIN_SETS = { + plain: CHAT_MARKDOWN_REMARK_PLUGINS, + breaks: CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, + math: [REMARK_MATH, ...CHAT_MARKDOWN_REMARK_PLUGINS], + mathBreaks: [REMARK_MATH, ...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS], +} satisfies Record; + +const combinedPlugins = new WeakMap>(); + +// One combined list per pair, so a stable extra list shares a renderer and its parse cache. +function withExtraPlugins(shared: PluggableList, extra: PluggableList): PluggableList { + if (extra.length === 0) return shared; + let byShared = combinedPlugins.get(extra); + if (!byShared) combinedPlugins.set(extra, (byShared = new WeakMap())); + let combined = byShared.get(shared); + if (!combined) byShared.set(shared, (combined = [...shared, ...extra])); + return combined; +} + +const markdownRenderers = new WeakMap< + PluggableList, + WeakMap> +>(); + +function markdownRenderer(remarkPlugins: PluggableList, rehypePlugins: PluggableList) { + let byRehype = markdownRenderers.get(remarkPlugins); + if (!byRehype) markdownRenderers.set(remarkPlugins, (byRehype = new WeakMap())); + let renderer = byRehype.get(rehypePlugins); + if (!renderer) { + renderer = createMarkdownRenderer({ + remarkPlugins, + rehypePlugins, + components: CHAT_MARKDOWN_COMPONENTS, + urlTransform: markdownUrlTransform, + }); + byRehype.set(rehypePlugins, renderer); + } + return renderer; +} + const MarkdownSource = memo(function MarkdownSource({ text, remarkPlugins, rehypePlugins, - urlTransform, + shared, + cache, }: { text: string; - remarkPlugins: NonNullable; - rehypePlugins: NonNullable; - urlTransform: ReactMarkdownOptions["urlTransform"]; + remarkPlugins: PluggableList; + rehypePlugins: PluggableList; + /** The plugins are one of the shared sets. */ + shared: boolean; + /** The text will not change again; streamed text would only fill the cache with prefixes. */ + cache: boolean; }) { + // Per-instance plugins, such as the incremental parser, set up their state on every render. + const render = shared + ? markdownRenderer(remarkPlugins, rehypePlugins) + : createMarkdownRenderer({ + remarkPlugins, + rehypePlugins, + components: CHAT_MARKDOWN_COMPONENTS, + urlTransform: markdownUrlTransform, + }); return ( - - - {text} - - + {render(text, shared && cache)} ); }); @@ -1997,14 +2053,8 @@ function ChatMarkdown({ linkBareGitHubReferences = false, ...props }: ChatMarkdownProps) { - const { - componentState, - handleCopy, - markdownRef, - markdownUrlTransform, - localMediaPreview, - closeLocalMediaPreview, - } = useChatMarkdownState({ text, inline, ...props }); + const { componentState, handleCopy, markdownRef, localMediaPreview, closeLocalMediaPreview } = + useChatMarkdownState({ text, inline, ...props }); // Streaming re-renders only the last block; offsets for task edits need the whole text. const streamingBlocks = props.isStreaming === true && @@ -2049,14 +2099,29 @@ function ChatMarkdown({ ]); } if (mayHaveColorCode(text)) referenceRemarkPlugins.push(remarkColorCodes); - const remarkPlugins = [ - ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), - ...extraRemarkPlugins, - ...referenceRemarkPlugins, - ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), - ]; - if (componentState.mathEnabled) - remarkPlugins.unshift([remarkMath, { singleDollarTextMath: true }]); + const sharedRemarkPlugins = + CHAT_MARKDOWN_REMARK_PLUGIN_SETS[ + componentState.mathEnabled + ? lineBreaks + ? "mathBreaks" + : "math" + : lineBreaks + ? "breaks" + : "plain" + ]; + // The incremental parser keeps per-instance state, so its output is not shared. + const uncachedPlugins = incrementalParsing || referenceRemarkPlugins.length > 0; + const remarkPlugins = incrementalParsing + ? [ + ...sharedRemarkPlugins, + ...extraRemarkPlugins, + ...referenceRemarkPlugins, + createIncrementalMarkdownPlugin(), + ] + : referenceRemarkPlugins.length > 0 + ? [...sharedRemarkPlugins, ...extraRemarkPlugins, ...referenceRemarkPlugins] + : withExtraPlugins(sharedRemarkPlugins, extraRemarkPlugins); + const blocks = streamingBlocks ?? [text]; return ( - {(streamingBlocks ?? [text]).map((source, index) => ( + {blocks.map((source, index) => ( ))} diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx new file mode 100644 index 0000000000000..8fe98b78da113 --- /dev/null +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -0,0 +1,64 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { ThreadId } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import type * as Context from "effect/Context"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { AppRoot } from "~/AppRoot"; +import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { getRouter } from "~/router"; +import { + ENVIRONMENT_ID, + PROJECT_ID, + installFakeEnvironment, + type FakeDriver, +} from "../../test/fakeEnvironment"; +import { render } from "../../test/render"; + +// The socket is the only fake layer; see test/appScenarios.renders.tsx. +vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { + const [actual, { fakeDriver }, Layer] = await Promise.all([ + importOriginal<{ readonly ConnectionDriver: Context.Key }>(), + import("../../test/fakeEnvironment"), + import("effect/Layer"), + ]); + return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; +}); + +vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ + ...(await original()), + trackRpcRequestSent: () => {}, +})); + +it("a new draft does not load its reserved thread before the server lists it", async () => { + using _environment = installFakeEnvironment([]); + using fetchSpy = vi.spyOn(globalThis, "fetch"); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const draftId = DraftId.make("draft-27443"); + const threadId = ThreadId.make("thread-27443"); + useComposerDraftStore + .getState() + .setProjectDraftThreadId(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), draftId, { threadId }); + + const router = getRouter(createMemoryHistory({ initialEntries: [`/draft/${draftId}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByTestId("composer-editor")).toBeVisible(); + // The shell snapshot has arrived once the sidebar lists the project, so the connection is up. + await expect.element(page.getByText("render", { exact: true }).first()).toBeVisible(); + + const threadRequests = fetchSpy.mock.calls + .map(([input]) => String(input instanceof Request ? input.url : input)) + .filter((url) => url.includes(`/api/orchestration/threads/${threadId}/`)); + // A 404 here marks the thread deleted, and the view never loads it once the send creates it. + expect(threadRequests).toEqual([]); +}); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 3304899216fcd..14125c684045d 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,5 +1,6 @@ import { boundAttachmentIds, + buildExpiredTerminalContextToastCopy, mergeDirectPreviewAnnotation, planComposerTurn, snapshotComposerSend, @@ -127,6 +128,22 @@ describe("deriveComposerSendState", () => { }); }); +describe("buildExpiredTerminalContextToastCopy", () => { + it("refers to several expired contexts as them", () => { + expect(buildExpiredTerminalContextToastCopy(2, "empty")).toEqual({ + title: "Expired terminal contexts won't be sent", + description: "Remove them or re-add them to include terminal output.", + }); + expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ + title: "Expired terminal contexts omitted from message", + description: "Re-add them if you want that terminal output included.", + }); + expect(buildExpiredTerminalContextToastCopy(1, "omitted").description).toBe( + "Re-add it if you want that terminal output included.", + ); + }); +}); + describe("hasServerAcknowledgedLocalDispatch", () => { it("acknowledges a settled newer background turn", () => { const localDispatch = createLocalDispatchSnapshot( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 89061036d92dc..a6a05ab0e37cc 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -531,6 +531,7 @@ export function resolveComposerProviderSelection(input: { : requestedInstanceId; return { selectedProviderEntry, + requestedInstanceId, requestedDriverKind, lockedContinuationGroupKey, unavailableProviderInstanceId, @@ -748,6 +749,7 @@ export function cloneComposerImageForRetry( export function deriveComposerSendState(options: { prompt: string; imageCount: number; + existingAttachmentCount?: number; terminalContexts: ReadonlyArray; elementContextCount?: number; }): { @@ -768,6 +770,7 @@ export function deriveComposerSendState(options: { hasSendableContent: trimmedPrompt.length > 0 || options.imageCount > 0 || + (options.existingAttachmentCount ?? 0) > 0 || sendableTerminalContexts.length > 0 || elementContextCount > 0, }; @@ -779,15 +782,16 @@ export function buildExpiredTerminalContextToastCopy( ): { title: string; description: string } { const count = Math.max(1, Math.floor(expiredTerminalContextCount)); const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts"; + const pronoun = count === 1 ? "it" : "them"; if (variant === "empty") { return { title: `${noun} won't be sent`, - description: "Remove it or re-add it to include terminal output.", + description: `Remove ${pronoun} or re-add ${pronoun} to include terminal output.`, }; } return { title: `${noun} omitted from message`, - description: "Re-add it if you want that terminal output included.", + description: `Re-add ${pronoun} if you want that terminal output included.`, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a9a9795c9b58f..2cc7c75fa32ca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -88,6 +88,7 @@ import { type AssistantCitation, type ChatFileAttachment, isProviderNativeSubagentThread, + orchestrationV2WorktreePending, type EnvironmentId, type MessageId, type ModelSelection, @@ -212,6 +213,7 @@ import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; +import { isInSideChatPane, type SideChatParentPanel } from "~/lib/sideChatPane"; import { NO_PROVIDER_MODEL_SELECTION, providerInstanceEntriesFor, @@ -298,6 +300,7 @@ import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; +import { useSideChat } from "./chat/useSideChat"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; @@ -415,6 +418,8 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + /** A side chat in its parent's right panel; the parent keeps the keyboard and workspace UI. */ + embedded?: SideChatParentPanel; routeKind: "server"; draftId?: never; } @@ -424,6 +429,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + embedded?: never; routeKind: "draft"; draftId: DraftId; }; @@ -438,7 +444,9 @@ export default function ChatView(props: ChatViewProps) { onDiffPanelOpen, reserveTitleBarControlInset = true, forceExpandedMobileComposer = false, + embedded: parentPanel, } = props; + const embedded = parentPanel !== undefined; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); const draftId = routeKind === "draft" ? props.draftId : null; @@ -924,7 +932,29 @@ export default function ChatView(props: ChatViewProps) { : scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId); // The title it had at delegation, until this client sees the parent itself. const delegatedFromThread = useQuietThreadShell(delegatedFromRef); + const sideChatParentRef = + activeThread?.lineage.sideChat === true && activeThread.lineage.parentThreadId !== null + ? scopeThreadRef(activeThread.environmentId, activeThread.lineage.parentThreadId) + : null; + const sideChatParent = useQuietThreadShell(sideChatParentRef); const parentThreadLink = (() => { + if (sideChatParentRef !== null) { + return { + title: sideChatParent?.title ?? "Parent thread", + sideChat: true, + // Embedded, the parent is the view around it. + ...(embedded + ? {} + : { + open: (): void => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(sideChatParentRef), + }); + }, + }), + }; + } if (parentSubagentThreadRef !== null) { return { title: parentSubagentThread?.title ?? "Parent thread", @@ -1130,7 +1160,9 @@ export default function ChatView(props: ChatViewProps) { renderedRightPanelSurface, ); const canMaximizeRightPanel = rightPanelOpen; - const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; + // An embedded side chat has no panel; a stale maximized flag would hide its own column. + const rightPanelMaximized = + !embedded && canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; const [threadPanelPopoverHandle] = useState(PopoverCreateHandle); const [chatCanvasStore] = useState(createChatCanvasStore); @@ -1838,13 +1870,15 @@ export default function ChatView(props: ChatViewProps) { providerSubagentModels, reportedModelSelection, ); - const showComposerContextStrip = shouldShowComposerContextStrip({ - isDraftHeroState, - persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null && !showProviderSubagentBar, - isGitRepo, - showEnvironmentIndicator: showComposerEnvironmentIndicator, - }); + const showComposerContextStrip = + !embedded && + shouldShowComposerContextStrip({ + isDraftHeroState, + persistInActiveThreads: settings.persistComposerContextStrip, + hasActiveProject: activeProject !== null && !showProviderSubagentBar, + isGitRepo, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + }); const terminalShortcutLabelOptions = { context: { terminalFocus: true, @@ -1934,7 +1968,10 @@ export default function ChatView(props: ChatViewProps) { }; const onInterruptStable = useStableCallback(onInterrupt); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window listener for the desktop snapshot event that focuses the composer - useEffect(() => subscribeSnapShotComposerFocus(focusComposerStable), [focusComposerStable]); + useEffect( + () => (embedded ? undefined : subscribeSnapShotComposerFocus(focusComposerStable)), + [embedded, focusComposerStable], + ); const scheduleComposerFocus = () => { window.requestAnimationFrame(() => { focusComposer(); @@ -2078,8 +2115,8 @@ export default function ChatView(props: ChatViewProps) { : linkedThreadPullRequestKey; const activeRunningTurnId = activeRuntime?.activeRunId ?? null; useProactivePanels({ - threadRef: isServerThread ? activeThreadRef : null, - enabled: settings.proactivePanelsEnabled && !shouldUsePlanSidebarSheet, + threadRef: isServerThread && !embedded ? activeThreadRef : null, + enabled: settings.proactivePanelsEnabled && !shouldUsePlanSidebarSheet && !embedded, settingsHydrated: clientSettingsHydrated, threadDetailLoading, runningTurnId: activeRunningTurnId, @@ -2212,14 +2249,26 @@ export default function ChatView(props: ChatViewProps) { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a frame to focus the composer DOM element when a thread opens with the terminal closed useEffect(() => { // Opening a split mounts the other pane; it must not take focus from the routed chat. - if (!activeThread?.id || terminalUiState.terminalOpen || unfocusedSplitPaneRef.current) return; + if ( + embedded || + !activeThread?.id || + terminalUiState.terminalOpen || + unfocusedSplitPaneRef.current + ) + return; const frame = window.requestAnimationFrame(() => { focusComposerStable(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposerStable, terminalUiState.terminalOpen, unfocusedSplitPaneRef]); + }, [ + activeThread?.id, + embedded, + focusComposerStable, + terminalUiState.terminalOpen, + unfocusedSplitPaneRef, + ]); const integrationShortcuts = useChatViewIntegrationShortcuts({ environmentId, routeThreadRef, @@ -2232,7 +2281,7 @@ export default function ChatView(props: ChatViewProps) { }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores the composer DOM focus when its window regains focus useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; + if (embedded || !activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; if (unfocusedSplitPane) return; let frame: number | null = null; const onWindowFocus = () => { @@ -2242,7 +2291,12 @@ export default function ChatView(props: ChatViewProps) { frame = window.requestAnimationFrame(() => { frame = null; if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return; - if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposerStable(); + if ( + !isInSideChatPane(document.activeElement) && + shouldRefocusComposerOnWindowFocus(document.activeElement) + ) { + focusComposerStable(); + } }); }); }; @@ -2257,6 +2311,10 @@ export default function ChatView(props: ChatViewProps) { isMobileViewport, terminalUiState.terminalOpen, unfocusedSplitPane, + embedded, + focusComposerStable, + isMobileViewport, + terminalUiState.terminalOpen, ]); // Promotion drops draftId while a send may still be dispatching, so key by thread. @@ -2502,6 +2560,7 @@ export default function ChatView(props: ChatViewProps) { pullRequestPanelTarget, threadPanelPresentation, isServerThread, + embedded, isGitRepo, browserAvailable, canOperatePreview, @@ -2555,6 +2614,7 @@ export default function ChatView(props: ChatViewProps) { activeThread, activeThreadId, activeThreadRef, + threadDetailRef: routeThreadDetailRef, activeThreadKey, activeThreadShell, activeLatestRun, @@ -2577,7 +2637,6 @@ export default function ChatView(props: ChatViewProps) { clearUsageLimitsFor, beginLocalDispatch, resetLocalDispatch, - scheduleComposerFocus, scrollToEnd, persistThreadSettingsForNextTurn, }); @@ -2876,8 +2935,8 @@ export default function ChatView(props: ChatViewProps) { // composer effect above. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the open drawer terminal when entering a thread useEffect(() => { - if (activeThreadRef) return focusTerminalOnThreadEnter(activeThreadRef); - }, [activeThreadRef]); + if (activeThreadRef && !embedded) return focusTerminalOnThreadEnter(activeThreadRef); + }, [activeThreadRef, embedded]); const getShortcutContext = (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, @@ -2886,7 +2945,9 @@ export default function ChatView(props: ChatViewProps) { previewOpen: previewPanelOpen, editableFocus: isEditableFocused(eventTarget), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, - composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor", + composerFocus: + document.activeElement?.getAttribute("data-testid") === "composer-editor" && + isInSideChatPane(document.activeElement) === embedded, draftThreadRoute: routeKind === "draft", turnRunning: phase === "running", composerRequiresModifier: composerRequiresModifier(settings.sendShortcut, promptRef.current), @@ -2907,6 +2968,8 @@ export default function ChatView(props: ChatViewProps) { useChatShortcuts({ enabled: !unfocusedSplitPane && activeThreadId !== null && !backgroundThread, + embedded, + composerElement: composerOverlayElement, keybindings, scriptKeybindings, getShortcutContext, @@ -2962,13 +3025,14 @@ export default function ChatView(props: ChatViewProps) { // A focused desktop browser page forwards these chords as menu actions. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to desktop bridge menu actions that toggle the right panel useEffect(() => { + if (embedded) return; return window.desktopBridge?.onMenuAction((action) => { if (action === "rightPanel.toggle") toggleRightPanelStable(); else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximizedStable(); }); - }, [toggleRightPanelStable, toggleRightPanelMaximizedStable]); + }, [embedded, toggleRightPanelStable, toggleRightPanelMaximizedStable]); - usePasteToFocusComposer(activeThreadId, composerRef, unfocusedSplitPane); + usePasteToFocusComposer(embedded ? null : activeThreadId, composerRef, unfocusedSplitPane); const { onConfirmRevertStable, onForkFromRunStable, @@ -2994,6 +3058,48 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, setThreadError, }); + // A delegated task is a remote thread's subagent. + const isSubagentThread = + activeThread?.lineage.relationshipToParent === "subagent" || delegatedFrom !== null; + // Side chats do not nest or start from subagents, and older servers would fork a listed thread. + const sideChatsOffered = + !embedded && + canOperateThread && + activeThread?.lineage.sideChat !== true && + !isSubagentThread && + serverConfig?.environment.capabilities.sideChats === true; + const sideChat = useSideChat({ + threadRef: sideChatsOffered && isServerThread ? activeThreadRef : null, + onError: (message) => { + if (activeThread) setThreadError(activeThread.id, message); + }, + }); + // A maximized panel covers the terminal drawer the command runs in. + const runSideChatShellCommandStable = useStableCallback((command: string) => { + if (activeThreadRef) useRightPanelStore.getState().setMaximized(activeThreadRef, false); + runShellCommandStable(command); + }); + // Main-thread surfaces show this reason; `/side` sent anyway gets it as a toast. + const sideChatDisabledReason = + activeThread?.lineage.sideChat === true + ? "Side chats cannot start side chats." + : isSubagentThread + ? "Subagent threads cannot start side chats." + : serverConfig === null + ? "Side chats are not ready yet. Try again in a moment." + : serverConfig.environment.capabilities.sideChats !== true + ? "Update this environment's T3 Code server to use side chats." + : sideChatsOffered && !isServerThread + ? "Side chats start after the first message." + : sideChat.available && + orchestrationV2WorktreePending( + activeThread?.worktreePath ?? null, + serverProjection?.runs ?? [], + ) + ? "Available once the thread's worktree is set up." + : null; + const openSideChatStable = useStableCallback(() => void sideChat.open()); + const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt)); const onCompactContext = () => { if (compactDisabled) return; void sendStandaloneCommand("/compact", "Failed to compact context."); @@ -3148,6 +3254,8 @@ export default function ChatView(props: ChatViewProps) { anchorNewTurn, acknowledgeActiveThreadWoke, handleInteractionModeChange, + onSideChat: sideChat.available ? sendSideChatStable : undefined, + sideChatDisabledReason, handleNewThreadInActiveProject, persistThreadSettingsForNextTurn, }); @@ -3297,10 +3405,7 @@ export default function ChatView(props: ChatViewProps) { !serverProjection?.messages.some((committed) => committed.id === message.id), ); const scrollToEndSummary = - [ - childInputNow === null ? null : "Subagent waiting", - queuedRunCount === 0 ? null : `${queuedRunCount} queued`, - ] + [childInputNow?.waitingLabel ?? null, queuedRunCount === 0 ? null : `${queuedRunCount} queued`] .filter((part) => part !== null) .join(" · ") || null; // The end of the timeline holds the thread's present and next turns: what it is doing now, @@ -3390,7 +3495,7 @@ export default function ChatView(props: ChatViewProps) { ); if (!activeThread) { - return ; + return embedded ? null : ; } const rightPanelContent = activeThreadRef ? ( @@ -3408,6 +3513,10 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext={getShortcutContextStable} onSendAnnotation={sendPreviewAnnotationStable} onCloseSurface={closeRightPanelSurface} + sideChatParentPanel={{ + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }} terminal={{ launchContext: activeTerminalLaunchContext ?? null, keybindings, @@ -3488,12 +3597,14 @@ export default function ChatView(props: ChatViewProps) { ref={setWorkspaceLayoutElement} className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background" > - - {rightPanelControlsAtRoot ? panelLayoutControls : null} + {embedded ? null : ( + + )} + {rightPanelControlsAtRoot && !embedded ? panelLayoutControls : null}
-
- {isElectron && rightPanelControlsAtRoot ? ( - + {isElectron && rightPanelControlsAtRoot ? ( + + ) : null} + {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} + {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} + - ) : null} - {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} - -
+ + )}
- {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? ( + {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser && !embedded ? ( ) : null} - {activeThreadRef && shownPreviewMiniPlayer ? ( + {activeThreadRef && shownPreviewMiniPlayer && !embedded ? ( ) : null} - + {embedded ? null : ( + <> + - - + + keybindings={keybindings} + availableEditors={availableEditors} + showOpenInPicker={showOpenInPicker} + openInShortcutEnabled={ + !( + rightPanelOpen && + renderedRightPanelSurface?.kind === "file" && + renderedRightPanelSurface.attachment === undefined + ) + } + gitCwd={gitCwd} + isGitRepo={isGitRepo} + envLocked={envLocked} + availableEnvironments={logicalProjectEnvironments} + autoEnvironmentLabel={autoEnvironmentLabel} + onAutoEnvironment={ + draftId && + !envLocked && + canAutoBalanceEnvironments && + loadBalancingSettings.loadBalancingEnabled + ? onAutoEnvironmentStable + : undefined + } + {...(canChangeEnvironment + ? { onEnvironmentChange: onEnvironmentChangeStable } + : {})} + onEnvModeChange={onEnvModeChangeStable} + envMode={envMode} + {...(canOverrideServerThreadEnvMode + ? { + activeThreadBranchOverride: activeThreadBranch, + onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, + } + : {})} + startFromOrigin={startFromOrigin} + onStartFromOriginChange={onStartFromOriginChange} + {...(canCheckoutPullRequestIntoThread + ? { onCheckoutPullRequestRequest: openPullRequestDialogStable } + : {})} + onComposerFocusRequest={scheduleComposerFocusStable} + {...(isServerThread && isGitRepo + ? { onOpenChanges: openChangesFromThreadPanelStable } + : {})} + conductorScripts={conductorScriptItems} + onRunConductorScript={runConductorScript} + onStopConductorScript={stopConductorScript} + onRunProjectScript={runProjectScriptStable} + onAddProjectScript={saveProjectScriptStable} + onUpdateProjectScript={updateProjectScriptStable} + onDeleteProjectScript={deleteProjectScriptStable} + usage={{ + providerInstanceId: activeProviderInstanceId, + providers: providerStatuses, + sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, + contextWindow: activeContextWindow, + }} + /> - + + + )}
- + {embedded ? null : ( + + )}
- {rightPanelPresent && activeThreadRef ? ( + {rightPanelPresent && activeThreadRef && !embedded ? ( {rightPanelContent} ) : null} - + {embedded ? null : } ({ + ...(await original()), + useAtomValue: (atom: unknown) => + atom === primaryServerKeybindingsAtom ? DEFAULT_RESOLVED_KEYBINDINGS : [], +})); +vi.mock("../hooks/useTheme", () => ({ + getRenderedAppearance: () => "light", + subscribeRenderedAppearance: () => () => {}, + useRenderedAppearance: () => "light", + useTheme: () => ({ theme: "t3-chat", resolvedTheme: "light", appearanceMode: "light" }), +})); +vi.mock("../hooks/useCustomThemes", () => ({ useCustomThemes: () => [] })); +vi.mock("../hooks/useEnvironmentTheme", () => ({ useEnvironmentThemeDefinitions: () => [] })); +vi.mock("../hooks/useSettings", async (original) => ({ + ...(await original()), + useClientSettings: () => DEFAULT_CLIENT_SETTINGS, +})); +vi.mock("../hooks/useScratchProject", () => ({ + useScratchProject: () => ({ scratchEnvironmentId: () => null }), +})); +vi.mock("../hooks/useNewProject", () => ({ useNewProject: () => async () => undefined })); +vi.mock("../hooks/useHandleNewThread", () => ({ + useHandleNewThread: () => ({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: null, + handleNewThread: async () => undefined, + }), + useNewThreadHandler: () => async () => undefined, +})); +vi.mock("../hooks/useOpenPanelPullRequestUrl", () => ({ useOpenPanelPullRequestUrl: () => null })); +vi.mock("../connection/useDesktopLocalBootstraps", () => ({ useDesktopLocalBootstraps: () => [] })); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: () => async () => ({ _tag: "Success", value: undefined }), +})); +vi.mock("../state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => async () => ({ _tag: "Success", value: undefined }), +})); +vi.mock("../state/environments", async (original) => ({ + ...(await original()), + useEnvironments: () => ({ + environments: [ + { + environmentId: "local", + label: "Local", + connection: { phase: "connected" }, + entry: { target: { _tag: "PrimaryConnectionTarget" } }, + serverConfig: { + environment: { platform: { os: "linux" }, capabilities: { projectCloneTracking: true } }, + settings: { addProjectBaseDirectory: "/workspace/" }, + }, + }, + ], + }), + usePrimaryEnvironmentId: () => "local", + useEnvironmentProviders: () => new Map(), +})); +vi.mock("../state/entities", async (original) => ({ + ...(await original()), + useProjects: () => [], + useThreadShells: () => [], + useServerConfigs: () => new Map(), + waitForProject: async () => undefined, +})); +vi.mock("../state/queries", async (original) => ({ + ...(await original()), + useThreadSearch: (_environmentIds: unknown, query: string) => ({ + matches: [], + query, + isPending: false, + }), +})); +vi.mock("../state/session", async (original) => ({ + ...(await original()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("../state/filesystem", async (original) => ({ + ...(await original()), + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), +})); +vi.mock("../state/query", async (original) => ({ + ...(await original()), + useEnvironmentQuery: () => ({ data: undefined, isLoading: false }), +})); +vi.mock("./settings/useAvailableSettingsSearchItems", () => ({ + useAvailableSettingsSearchItems: () => [], +})); + +// The real palette and Base UI input must agree that confirming composition is typing. +describe.each(["repository", "folder", "destination"] as const)("add-project %s", (step) => { + it.each([ + { isComposing: true, keyCode: 13 }, + { isComposing: false, keyCode: 229 }, + ])("keeps Enter in the input during composition: %j", async (composition) => { + await using _screen = await render({null}); + await act(() => openCommandPalette({ open: "add-project" })); + await page.getByText(step === "folder" ? "Local folder" : "Git URL", { exact: true }).click(); + const input = () => page.getByRole("combobox"); + if (step === "destination") { + await input().fill("https://example.com/team/project.git"); + await userEvent.keyboard("{Enter}"); + } + const value = step === "repository" ? "https://example.com/team/計画.git" : "/workspace/計画"; + await input().fill(value); + const event = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + ...composition, + }); + await act(() => input().element().dispatchEvent(event)); + expect(event.defaultPrevented).toBe(false); + await expect.element(input()).toHaveValue(value); + if (step === "repository") { + await userEvent.keyboard("{Enter}"); + await expect.element(input()).toHaveValue("/workspace/計画"); + } else { + await userEvent.keyboard("{Enter}"); + await expect.element(page.getByRole("combobox")).not.toBeInTheDocument(); + } + }); +}); diff --git a/apps/web/src/components/CommandPalette.focus.browser.test.tsx b/apps/web/src/components/CommandPalette.focus.browser.test.tsx index 254b4f58e58da..b3d963cbc5ff3 100644 --- a/apps/web/src/components/CommandPalette.focus.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.focus.browser.test.tsx @@ -1,8 +1,9 @@ +import { dispatchCommandPalette } from "../commandPaletteStore"; import { useImperativeHandle, useRef } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; -import { isMacPlatform, DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { CommandPalette } from "./CommandPalette"; import { useComposerHandleContext } from "../composerHandleContext"; import { openCommandPalette } from "../commandPaletteBus"; @@ -124,18 +125,7 @@ describe("command palette focus restoration", () => { ); const field = screen.getByRole("textbox", { name: "Settings field" }); await userEvent.click(field); - const mac = isMacPlatform(navigator.platform); - await act(() => - field.element().dispatchEvent( - new KeyboardEvent("keydown", { - key: "p", - ctrlKey: !mac, - metaKey: mac, - bubbles: true, - cancelable: true, - }), - ), - ); + await act(() => dispatchCommandPalette({ _tag: "OpenFileSearch", query: "" })); await expect.element(page.getByRole("dialog", { name: "File picker" })).toBeInTheDocument(); await userEvent.keyboard("{Escape}"); await expect.element(page.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument(); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 33dde6d95f5be..94fa355a52e82 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -4,6 +4,7 @@ import type { Thread } from "../types"; import { makeThreadFixture } from "../test-fixtures"; import { buildNestedSearchItems, + resolveCommandPaletteProject, buildThreadActionItems, buildLinkedThreadActionItems, filterPinnedBrowseEntries, @@ -974,3 +975,22 @@ describe("returning from file and content search", () => { expect(reduceCommandPaletteUiState(closed, { _tag: "SetOpen", open: true })).toEqual(root); }); }); + +it("resolves project metadata and browse roots within the active environment", () => { + const local = { title: "Local project", workspaceRoot: "/local/repo" }; + const remote = { title: "Remote project", workspaceRoot: "/remote/repo" }; + const projects = new Map([ + ["local:shared-project", local], + ["remote:shared-project", remote], + ]); + expect( + resolveCommandPaletteProject(projects, EnvironmentId.make("local"), PROJECT_ID), + ).toBeUndefined(); + const sharedId = ProjectId.make("shared-project"); + expect(resolveCommandPaletteProject(projects, EnvironmentId.make("local"), sharedId)).toBe(local); + expect(resolveCommandPaletteProject(projects, EnvironmentId.make("remote"), sharedId)).toBe( + remote, + ); + expect(resolveCommandPaletteProject(projects, null, sharedId)).toBeUndefined(); + expect(resolveCommandPaletteProject(projects, EnvironmentId.make("local"), null)).toBeUndefined(); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 86d27afb9cc67..4f31de29f30c1 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -20,6 +20,7 @@ import { } from "@t3tools/client-runtime/operations/projects"; import { type EnvironmentId, + type ProjectId, type EnvironmentMachineKind, type FilesystemBrowseEntry, type KeybindingCommand, @@ -31,7 +32,7 @@ import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/fil import { type ReactNode } from "react"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; -import { isSidebarSubagentThread } from "./Sidebar.logic"; +import { isSidebarHiddenThread } from "./Sidebar.logic"; export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; @@ -449,7 +450,7 @@ export function buildThreadActionItems thread.archivedAt === null && !isSidebarSubagentThread(thread), + (thread) => thread.archivedAt === null && !isSidebarHiddenThread(thread), ); return visibleThreads.map((thread) => { @@ -889,3 +890,11 @@ export function overlayModeForCommand(command: string | null): SearchOverlayMode ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] : null; } + +export function resolveCommandPaletteProject( + projects: ReadonlyMap, + environmentId: EnvironmentId | null, + projectId: ProjectId | null, +) { + return environmentId && projectId ? projects.get(`${environmentId}:${projectId}`) : undefined; +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index dfb667d44023b..c28f5119bed9c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -184,6 +184,7 @@ import { resolveWslProjectSelection, } from "../wslPaths"; import { + resolveCommandPaletteProject, ADDON_ICON_CLASS, browseInputEndPaddingClass, buildBrowseGroups, @@ -705,7 +706,7 @@ function OpenCommandPaletteDialog(props: { return ensureBrowseDirectoryPath(baseDirectory); }; - const { projectCwdById, projectByKey, projectTitleById } = useProjectLookups(); + const { projectByKey } = useProjectLookups(); const activeThreadKey = activeThread ? scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)) @@ -740,9 +741,12 @@ function OpenCommandPaletteDialog(props: { const canTriage = useAtomValue(serverEnvironment.prepareTriage.permissionAtom(triageEnvironmentId)) && supportsTriage(triageEnvironmentId); - const currentProjectCwd = currentProjectId - ? (projectCwdById.get(currentProjectId) ?? null) - : null; + const currentProject = resolveCommandPaletteProject( + projectByKey, + currentProjectEnvironmentId, + currentProjectId, + ); + const currentProjectCwd = currentProject?.workspaceRoot ?? null; const currentProjectCwdForBrowse = browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId ? currentProjectCwd @@ -1268,7 +1272,8 @@ function OpenCommandPaletteDialog(props: { if (projects.length > 0) { const activeProjectTitle = projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? - (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); + currentProject?.title ?? + null; if (activeProjectTitle) { actionItems.push({ @@ -2669,6 +2674,12 @@ function OpenCommandPaletteDialog(props: { } function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Enter" && (event.nativeEvent.isComposing || event.keyCode === 229)) { + // Confirming an IME candidate must not submit a path or select a row. + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + return; + } + const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, context: { modelPickerOpen: false, editableFocus: true }, diff --git a/apps/web/src/components/CommandPaletteLookups.ts b/apps/web/src/components/CommandPaletteLookups.ts index f4fcfc5793bea..6c9fcc629f732 100644 --- a/apps/web/src/components/CommandPaletteLookups.ts +++ b/apps/web/src/components/CommandPaletteLookups.ts @@ -1,7 +1,6 @@ import { type ClientSettings, type EnvironmentId, - type ProjectId, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; @@ -41,16 +40,10 @@ export function useEnvironmentLookups() { export function useProjectLookups() { const projects = useProjects(); - const projectCwdById = new Map( - projects.map((project) => [project.id, project.workspaceRoot]), - ); const projectByKey = new Map( projects.map((project) => [`${project.environmentId}:${project.id}`, project]), ); - const projectTitleById = new Map( - projects.map((project) => [project.id, project.title]), - ); - return { projectCwdById, projectByKey, projectTitleById }; + return { projectByKey }; } export const selectCommandPaletteClientSettings = (settings: ClientSettings) => ({ diff --git a/apps/web/src/components/ComposerContextPermissions.tsx b/apps/web/src/components/ComposerContextPermissions.tsx index 8ee7766edc565..b4ac2a2eb6983 100644 --- a/apps/web/src/components/ComposerContextPermissions.tsx +++ b/apps/web/src/components/ComposerContextPermissions.tsx @@ -26,6 +26,7 @@ export function ComposerContextPermissions(props: { size="xs" className="ms-auto min-w-0 shrink-0" aria-label={`Permissions: ${label}`} + aria-haspopup="menu" data-composer-context-control onClick={props.onOpen} /> diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index 89f56c4ad83bb..3b19f66793af2 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -54,6 +54,10 @@ const contextChipVariants = cva( // The accent is the colour itself, set through `style`. color: "", }, + shape: { + inline: "", + bubble: "w-64 gap-2 rounded-2xl bg-message px-3 py-2 text-sm text-message-foreground", + }, state: { unresolved: "border-dashed", loading: "border-dashed", @@ -95,11 +99,12 @@ type ContextChipKind = NonNullable["kin interface ContextChipProps extends useRender.ComponentProps<"span"> { kind?: ContextChipKind; state?: "unresolved" | "loading" | "invalid"; + shape?: "inline" | "bubble"; } -function ContextChip({ className, kind, state, render, ...props }: ContextChipProps) { +function ContextChip({ className, kind, state, shape, render, ...props }: ContextChipProps) { const defaultProps = { - className: cn(contextChipVariants({ kind, state }), className), + className: cn(contextChipVariants({ kind, state, shape }), className), "data-slot": "context-chip", "data-state": state, }; diff --git a/apps/web/src/components/DiffFilePathCopyButton.browser.test.tsx b/apps/web/src/components/DiffFilePathCopyButton.browser.test.tsx new file mode 100644 index 0000000000000..a2bec09ca960d --- /dev/null +++ b/apps/web/src/components/DiffFilePathCopyButton.browser.test.tsx @@ -0,0 +1,30 @@ +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; +import { AnchoredToastProvider } from "./ui/toast"; + +it("explains a denied clipboard write and lets the same path be copied again", async () => { + await using screen = await render( + + + , + ); + using write = vi + .spyOn(navigator.clipboard, "writeText") + .mockRejectedValueOnce(new DOMException("Clipboard access denied", "NotAllowedError")) + .mockResolvedValue(undefined); + using _console = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const copy = screen.getByRole("button", { name: "Copy file path" }); + await copy.click(); + + await expect.element(page.getByText("Failed to copy text to the clipboard.")).toBeVisible(); + await page.getByRole("button", { name: "Dismiss notification" }).click(); + await copy.click(); + + await expect.element(page.getByText("Copied!", { exact: true })).toBeVisible(); + expect(write).toHaveBeenCalledTimes(2); + expect(write).toHaveBeenLastCalledWith("apps/web/src/index.ts"); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 89ae441367795..5c202fccf5abc 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -94,6 +94,36 @@ describe("when: on default ref without open PR", () => { }); }); +describe("when: local changes are behind upstream", () => { + it("keeps the commit action available without pushing, with or without an open PR", () => { + const behind = status({ hasWorkingTreeChanges: true, behindCount: 2 }); + const commit = { + label: "Commit", + disabled: false, + kind: "run_action", + action: "commit", + } as const; + assert.deepEqual(resolveQuickAction(behind, false), commit); + assert.deepEqual( + resolveQuickAction( + { + ...behind, + pr: { + number: 13, + title: "Open PR", + url: "https://example.com/pr/13", + baseRef: "main", + headRef: "feature/test", + state: "open", + }, + }, + false, + ), + commit, + ); + }); +}); + describe("when: ref has no upstream configured", () => { it("resolveQuickAction runs push and create PR when no upstream and commits are ahead", () => { const quick = resolveQuickAction( diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 65123285a78fc..98a55c3577e01 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -212,7 +212,7 @@ export function resolveQuickAction( } if (hasChanges) { - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { + if (isBehind || (!gitStatus.hasUpstream && !hasPrimaryRemote)) { return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; } if (hasOpenPr || isDefaultRef) { diff --git a/apps/web/src/components/GitHubStarPrompt.test.ts b/apps/web/src/components/GitHubStarPrompt.test.ts new file mode 100644 index 0000000000000..34f763646d6dd --- /dev/null +++ b/apps/web/src/components/GitHubStarPrompt.test.ts @@ -0,0 +1,91 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { toastManager } from "./ui/toast"; + +const { add, starred } = vi.hoisted(() => ({ + add: vi.fn(), + starred: { value: false, duringCheck: () => {} }, +})); +vi.mock("./ui/toast", () => ({ toastManager: { add, close: vi.fn() } })); +vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("../state/sourceControl", () => ({ sourceControlEnvironment: { checkStarred: {} } })); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + runAtomCommand: async () => { + starred.duringCheck(); + return { _tag: "Success", value: starred.value }; + }, +})); + +const environmentId = EnvironmentId.make("environment-1"); + +function setup({ sent = 0, writable = true, alreadyStarred = false } = {}) { + add.mockReset(); + starred.value = alreadyStarred; + starred.duringCheck = () => {}; + const store = new Map([["t3code:github-star-prompt:sent", String(sent)]]); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + if (!writable) throw new Error("QuotaExceededError"); + store.set(key, value); + }, + }, + }); + return store; +} + +// A fresh module import stands in for an app reload; storage survives it. +async function startSession() { + vi.resetModules(); + const session = await import("./GitHubStarPrompt"); + return { send: () => session.recordPromptSent(environmentId) }; +} + +describe("recordPromptSent", () => { + it("asks after five prompts, once per session, and never again once closed", async () => { + setup(); + let session = await startSession(); + for (let sent = 0; sent < 5; sent++) await session.send(); + expect(add).not.toHaveBeenCalled(); + + await session.send(); + await session.send(); + expect(add).toHaveBeenCalledOnce(); + + session = await startSession(); + await session.send(); + expect(add).toHaveBeenCalledTimes(2); + + add.mock.lastCall?.[0].onClose?.(); + session = await startSession(); + for (let sent = 0; sent < 10; sent++) await session.send(); + expect(add).toHaveBeenCalledTimes(2); + vi.unstubAllGlobals(); + }); + + it("never asks when a dismissal could not be saved", async () => { + setup({ sent: 5, writable: false }); + await (await startSession()).send(); + expect(add).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it("never asks once the environment's GitHub account starred the repo", async () => { + setup({ sent: 5, alreadyStarred: true }); + await (await startSession()).send(); + starred.value = false; + await (await startSession()).send(); + expect(add).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it("never asks when another tab dismisses it during the star check", async () => { + const store = setup({ sent: 5 }); + starred.duringCheck = () => store.set("t3code:github-star-prompt:dismissed", "true"); + await (await startSession()).send(); + expect(add).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); +}); diff --git a/apps/web/src/components/GitHubStarPrompt.tsx b/apps/web/src/components/GitHubStarPrompt.tsx new file mode 100644 index 0000000000000..d157d72960d93 --- /dev/null +++ b/apps/web/src/components/GitHubStarPrompt.tsx @@ -0,0 +1,94 @@ +import { type EnvironmentId, SourceControlProviderKind } from "@t3tools/contracts"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Schema from "effect/Schema"; +import { StarIcon } from "lucide-react"; + +import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; +import { readLocalApi } from "../localApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { toastManager } from "./ui/toast"; + +// Separate keys so a count written by another tab can never undo a dismissal. +const SENT_KEY = "t3code:github-star-prompt:sent"; +const DISMISSED_KEY = "t3code:github-star-prompt:dismissed"; +const REPOSITORY = "pingdotgg/t3code"; +const PROMPTS_BEFORE_ASKING = 5; + +let shownThisSession = false; + +function dismiss() { + try { + setLocalStorageItem(DISMISSED_KEY, true, Schema.Boolean); + } catch {} +} + +// Every close path, including both buttons, runs `onClose`, which saves the dismissal. +function showStarPrompt() { + const close = () => toastManager.close(id); + const id = toastManager.add({ + title: "Enjoying T3 Code?", + description: "Give us a star on GitHub to let us know!", + timeout: 0, + onClose: dismiss, + actionProps: { + children: "Star on GitHub", + onClick: () => { + close(); + void readLocalApi() + ?.shell.openExternal(`https://github.com/${REPOSITORY}`) + .catch(() => undefined); + }, + }, + data: { + actionLayout: "stacked-end", + leadingIcon: , + secondaryActionProps: { children: "Dismiss", onClick: close }, + secondaryActionVariant: "ghost", + }, + }); +} + +// The server's GitHub account stands in for the user's; it may not be theirs on a shared server. +async function alreadyStarred(environmentId: EnvironmentId) { + const result = await runAtomCommand( + appAtomRegistry, + sourceControlEnvironment.checkStarred, + { + environmentId, + input: { + provider: SourceControlProviderKind.make("github"), + host: "github.com", + repository: REPOSITORY, + }, + }, + { label: "check GitHub star", reportFailure: false, reportDefect: false }, + ); + return result._tag === "Success" && result.value; +} + +/** + * Counts a sent prompt. After more than five, asks once per session for a star until dismissed, + * unless the environment's GitHub account already starred the repo. + */ +export async function recordPromptSent(environmentId: EnvironmentId): Promise { + if (shownThisSession) return; + try { + if (getLocalStorageItem(DISMISSED_KEY, Schema.Boolean)) return; + const sent = (getLocalStorageItem(SENT_KEY, Schema.Number) ?? 0) + 1; + setLocalStorageItem(SENT_KEY, sent, Schema.Number); + if (sent <= PROMPTS_BEFORE_ASKING) return; + } catch { + // Storage that cannot be read or written could not keep a dismissal, so never ask. + return; + } + shownThisSession = true; + if (await alreadyStarred(environmentId)) return dismiss(); + try { + // Another tab can dismiss while the check runs. + if (getLocalStorageItem(DISMISSED_KEY, Schema.Boolean)) return; + } catch { + return; + } + showStarPrompt(); +} diff --git a/apps/web/src/components/ImportConversationDialog.tsx b/apps/web/src/components/ImportConversationDialog.tsx index 517f4f3fd8ee2..121508a6506ef 100644 --- a/apps/web/src/components/ImportConversationDialog.tsx +++ b/apps/web/src/components/ImportConversationDialog.tsx @@ -175,6 +175,7 @@ function ImportConversationDialog({ } const PROVIDER_LABEL: Record = { + pi: "Pi", claudeAgent: "Claude Code", codex: "Codex", }; @@ -484,6 +485,7 @@ function ImportConversationList({ total: number; } | null>(null); const importRun = useRef<{ cancelled: boolean } | null>(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the pending import when the dialog unmounts useEffect( () => () => { if (importRun.current) importRun.current.cancelled = true; @@ -947,7 +949,7 @@ function ImportConversationList({ {row.workspace.branch ?? "No branch"} ) : ( -
—
+
None
)} {showProject ? ( diff --git a/apps/web/src/components/PersistentThreadTerminals.tsx b/apps/web/src/components/PersistentThreadTerminals.tsx index 5e2fe0dbe91f5..a387349b4c038 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/PersistentThreadTerminals.tsx @@ -294,7 +294,7 @@ export interface PersistentThreadTerminalPanelProps { onSplitTerminalVertical: () => void; onNewTerminal: () => void; onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; + onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; diff --git a/apps/web/src/components/ProjectFavicon.browser.test.tsx b/apps/web/src/components/ProjectFavicon.browser.test.tsx new file mode 100644 index 0000000000000..746302ef5b242 --- /dev/null +++ b/apps/web/src/components/ProjectFavicon.browser.test.tsx @@ -0,0 +1,57 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId } from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; + +const state = vi.hoisted(() => ({ favicon: undefined as Atom.Atom | undefined })); +vi.mock(import("../state/assets"), () => ({ + projectFaviconUrlAtom: () => { + if (!state.favicon) throw new Error("Favicon resource not configured"); + return state.favicon; + }, +})); + +function setup() { + const activeResources = new Set(); + state.favicon = Atom.make((get) => { + activeResources.add("favicon"); + get.addFinalizer(() => activeResources.delete("favicon")); + return null; + }); + return { activeResources }; +} + +it("only activates the favicon resource after removing a custom icon", async () => { + const { activeResources } = setup(); + const registry = AtomRegistry.make(); + const project: ProjectFaviconProject = { + environmentId: EnvironmentId.make("test"), + workspaceRoot: "/project", + title: "Project", + faviconPath: "icon.png", + projectIcon: { kind: "monogram", text: "PR", color: "blue" }, + }; + const view = (projectIcon: ProjectFaviconProject["projectIcon"]) => ( + + + + ); + try { + await using screen = await render(view(project.projectIcon)); + expect(activeResources.size).toBe(0); + await screen.rerender(view({ kind: "emoji", emoji: "🌱" })); + expect(activeResources.size).toBe(0); + await screen.rerender(view({ kind: "lucide", name: "leaf", color: "green" })); + expect(activeResources.size).toBe(0); + await screen.rerender(view(null)); + expect(activeResources.size).toBe(1); + await screen.rerender(view(project.projectIcon)); + await screen.rerender(view(null)); + expect(activeResources.size).toBe(1); + } finally { + registry.dispose(); + } +}); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 14a00ae86583b..3545a0aa5db87 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -38,13 +38,6 @@ export function ProjectFavicon(input: { fallbackIcon?: ComponentType<{ className?: string }>; }) { const { project } = input; - const src = useAtomValue( - projectFaviconUrlAtom({ - environmentId: project.environmentId, - cwd: project.workspaceRoot, - faviconPath: project.faviconPath, - }), - ); if (project.projectIcon?.kind === "monogram") { return ( ); } + return ; +} + +function AutomaticProjectFavicon(input: Parameters[0]) { + const { project } = input; + const src = useAtomValue( + projectFaviconUrlAtom({ + environmentId: project.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath, + }), + ); const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon; if (!src || isProjectFaviconFallbackUrl(src)) { diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index f1b5520d4216a..ce187f5db5e96 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -91,6 +91,7 @@ export function ReopenClosedViewShortcut() { environmentKnown: true, catalogReady: true, ownerExists: true, + sideChatExists: true, shellLive: true, panel, }; @@ -99,6 +100,10 @@ export function ReopenClosedViewShortcut() { environmentKnown: catalog.entries.has(ref.environmentId), catalogReady: catalog.isReady, ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null, + sideChatExists: + entry.kind !== "panel-tab" || + entry.surface.kind !== "side-chat" || + readThreadShell(scopeThreadRef(ref.environmentId, entry.surface.threadId)) !== null, shellLive: appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live", panel, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 8e3b6f708fa66..b2b8197e56dee 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,15 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + threadRuntimeIsActive, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + useProjects, + useQuietThreadShell, + useServerConfigs, + useThreadShells, +} from "~/state/entities"; import { threadPullRequestKeysEqual, visibleThreadPullRequests, @@ -12,6 +21,7 @@ import type { ProjectId, PullRequestState, ResolvedKeybindingsConfig, + ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { @@ -34,6 +44,7 @@ import { FileDiff, Files, Globe2, + MessagesSquare, Plus, TerminalSquare, } from "lucide-react"; @@ -133,6 +144,8 @@ export interface RightPanelTabsProps { onAddPullRequests: () => void; onAddAgents: () => void; onAddDevice: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -141,6 +154,9 @@ export interface RightPanelTabsProps { pullRequestsAvailable: boolean; agentsAvailable: boolean; deviceAvailable: boolean; + sideChatAvailable?: boolean; + /** Overrides the default reason while side chats are available but cannot start. */ + sideChatDisabledReason?: string | null; pullRequestStatusSeeds?: Readonly>; /** Fork: working subagents; badges the Agents row in the empty state. */ liveAgentCount: number; @@ -170,6 +186,7 @@ const SURFACE_DISABLED_REASONS = { pullRequests: "No linked pull requests are available for this thread.", agents: "Agents are only available from a thread.", device: "Devices are only available from a thread.", + sideChat: "Side chats start after the first message.", } as const; // Closed keepMounted popups must stop blocking shortcuts once their exit transition ends. @@ -195,6 +212,7 @@ const SURFACE_UNAVAILABLE_HINTS = { pullRequests: "No linked pull requests available.", agents: "Available from a thread.", device: "Available from a thread.", + sideChat: "Available after the first message.", } as const; type TabContextMenuAction = @@ -313,6 +331,8 @@ function RightPanelEmptyState(props: { onAddPullRequests: () => void; onAddAgents: () => void; onAddDevice: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -322,6 +342,8 @@ function RightPanelEmptyState(props: { agentsAvailable: boolean; deviceAvailable: boolean; liveAgentCount: number; + sideChatAvailable: boolean; + sideChatDisabledReason: string | null; }) { const [highlight, setHighlight] = useState(-1); @@ -392,6 +414,19 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_UNAVAILABLE_HINTS.device, onClick: props.onAddDevice, }, + ...(props.onAddSideChat + ? [ + { + label: "Side chat", + description: "Ask a side question while this thread keeps working.", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_UNAVAILABLE_HINTS.sideChat, + onClick: props.onAddSideChat, + }, + ] + : []), ] as const; type SurfaceAction = (typeof actions)[number]; @@ -591,6 +626,8 @@ function surfaceTitle( return surface.title; case "device": return surface.title ?? surface.target?.name ?? "Device"; + case "side-chat": + return "Side chat"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -682,6 +719,8 @@ function SurfaceIcon({ case "agents": case "agent": return ; + case "side-chat": + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -693,6 +732,41 @@ function SurfaceIcon({ } } +/** What a side chat tab's dot shows and its title's accessible name says. */ +function sideChatTabState(shell: EnvironmentThreadShell | null) { + if (shell === null) return null; + if (shell.hasPendingApprovals || shell.pendingSubagentRequest === "approval") { + return "needs approval"; + } + if (shell.hasPendingUserInput || shell.pendingSubagentRequest === "user_input") { + return "needs input"; + } + return threadRuntimeIsActive(shell.runtime) ? "running" : null; +} + +// Static dots: a side chat tab can stay open for a whole run. +function SideChatTabIcon(props: { environmentId: EnvironmentId | null; threadId: ThreadId }) { + const state = sideChatTabState( + useQuietThreadShell( + props.environmentId === null ? null : scopeThreadRef(props.environmentId, props.threadId), + ), + ); + return ( + <> + + {state === null ? null : ( + + )} + + ); +} + export function resolvePullRequestTabLink( threads: readonly Pick[], environmentId: EnvironmentId | null, @@ -850,7 +924,6 @@ function RightPanelTab( ) { const { surface, - title, audio, audioRuntimeTabId, onActivate, @@ -864,6 +937,13 @@ function RightPanelTab( id: surface.id, disabled: props.dragDisabled, }); + const sideChatShell = useQuietThreadShell( + surface.kind === "side-chat" && props.environmentId !== null + ? scopeThreadRef(props.environmentId, surface.threadId) + : null, + ); + const title = sideChatShell?.title ?? props.title; + const sideChatState = sideChatTabState(sideChatShell); const content = ( <> onClose(surface)}> @@ -933,6 +1013,7 @@ function RightPanelTab( if (surface.kind === "device" && props.canRename) onStartRename(surface.id); }} className="cursor-pointer flex min-w-0 items-center" + aria-label={sideChatState === null ? undefined : `${title}, ${sideChatState}`} onClick={() => onActivate(surface)} > {title} @@ -1111,6 +1192,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.device, onClick: props.onAddDevice, }, + ...(props.onAddSideChat + ? [ + { + label: "Side chat", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable === true && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_DISABLED_REASONS.sideChat, + onClick: props.onAddSideChat, + }, + ] + : []), ] as const; const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { @@ -1486,6 +1579,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddPullRequests={props.onAddPullRequests} onAddAgents={props.onAddAgents} onAddDevice={props.onAddDevice} + onAddSideChat={props.onAddSideChat} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} @@ -1495,6 +1589,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { agentsAvailable={props.agentsAvailable} deviceAvailable={props.deviceAvailable} liveAgentCount={props.liveAgentCount} + sideChatAvailable={props.sideChatAvailable === true} + sideChatDisabledReason={props.sideChatDisabledReason ?? null} /> ) : ( props.children diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 8af07d24542b6..58eba868f7be6 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,3 +1,4 @@ +import { isListedThreadLineage } from "@t3tools/contracts"; import { presentThreadShell } from "@t3tools/client-runtime/state/models"; import { isThreadWorking as isSidebarThreadWorking } from "@t3tools/client-runtime/state/thread-inbox"; import * as DateTime from "effect/DateTime"; @@ -29,7 +30,6 @@ import { getSidebarThreadIdsToPrewarm, hasUnseenCompletion, isContextMenuPointerDown, - isSidebarSubagentThread, resolveSidebarRowThreadId, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -53,6 +53,7 @@ import { sortInboxThreadsByReturn, resolveSidebarDropTarget, planSidebarThreadDrop, + resolveSidebarReorderableThreadKeys, sortPinnedThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -88,6 +89,11 @@ import { type SidebarPage, repositoryOrganisationOf, } from "./sidebar/organization.logic"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + sortActiveThreadsByOrderKey, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; import { resolveProjectExpanded } from "../uiStateStore"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; @@ -528,8 +534,8 @@ describe("sidebar thread lineage helpers", () => { }, }); - expect(isSidebarSubagentThread(subagent)).toBe(true); - expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false); + expect(!isListedThreadLineage(subagent.lineage)).toBe(true); + expect(!isListedThreadLineage(makeThreadFixture().lineage)).toBe(false); }); it("maps an open subagent to its nearest visible ancestor's sidebar row", () => { @@ -2772,6 +2778,240 @@ describe("Working shelf (beta)", () => { expect(above.kind === "move-active" && above.assignments[0]!.orderKey < "m").toBe(true); }); + describe("reordering with cached offline configuration", () => { + const onlineEnvironmentId = EnvironmentId.make("online"); + const offlineEnvironmentId = EnvironmentId.make("offline"); + const capabilities = { + repositoryIdentity: true, + threadPinning: true, + threadPinReorder: true, + threadActiveReorder: true, + }; + const config = (environmentId: EnvironmentId) => ({ + environment: { + environmentId, + label: environmentId, + platform: { os: "linux" as const, arch: "x64" as const }, + serverVersion: "0.1.1", + capabilities, + }, + }); + const serverConfigs = new Map([ + [onlineEnvironmentId, config(onlineEnvironmentId)], + [offlineEnvironmentId, config(offlineEnvironmentId)], + ]); + const thread = ( + id: string, + environmentId = onlineEnvironmentId, + key: string | null = null, + ) => ({ + id: ThreadId.make(id), + environmentId, + createdAt: `2026-03-09T${id === "offline" ? "12" : id === "online" ? "11" : "10"}:00:00.000Z`, + activeOrderKey: key, + pinOrderKey: key, + }); + const keyOf = (entry: ReturnType) => + scopedThreadKey(scopeThreadRef(entry.environmentId, entry.id)); + const online = thread("online"); + const offline = thread("offline", offlineEnvironmentId); + const membership = (connectedEnvironmentIds = [onlineEnvironmentId]) => + resolveSidebarReorderableThreadKeys({ + threads: [online, offline], + serverConfigs, + connectedEnvironmentIds, + }); + const planDrop = (input: { + section: "active" | "pinned"; + rows: ReturnType[]; + moved: ReturnType; + order: ReturnType[]; + from?: "active" | "pinned"; + connectedEnvironmentIds?: EnvironmentId[]; + }) => { + const writable = resolveSidebarReorderableThreadKeys({ + threads: input.rows, + serverConfigs, + connectedEnvironmentIds: input.connectedEnvironmentIds ?? [onlineEnvironmentId], + }); + const sort = (rows: typeof input.rows) => + input.section === "active" + ? sortActiveThreadsByOrderKey(rows) + : sortPinnedThreadsByOrderKey(rows); + const originalOrder = sort(input.rows).map(keyOf); + const targetOrder = input.order.map(keyOf); + const keysById = new Map(input.rows.map((entry) => [keyOf(entry), entry.activeOrderKey])); + const plan = planSidebarThreadDrop({ + activeKey: keyOf(input.moved), + activeSection: input.from ?? input.section, + target: { section: input.section, pinnedOrder: targetOrder, activeOrder: targetOrder }, + pinnedOrder: originalOrder, + activeOrder: originalOrder, + pinnedKeysById: keysById, + activeKeysById: keysById, + reorderableKeys: writable.draggableThreadKeys, + activeReorderableKeys: writable.activeReorderableThreadKeys, + }); + const assignments = + plan.kind === "move-active" || plan.kind === "reorder-pinned" + ? plan.assignments + : plan.kind === "pin" + ? [ + ...plan.extraAssignments, + ...(plan.orderKey === undefined + ? [] + : [{ id: keyOf(input.moved), orderKey: plan.orderKey }]), + ] + : []; + const assignedKeys = new Map(assignments.map(({ id, orderKey }) => [id, orderKey])); + const actualOrder = sort( + input.rows.map((entry) => ({ + ...entry, + activeOrderKey: assignedKeys.get(keyOf(entry)) ?? entry.activeOrderKey, + pinOrderKey: assignedKeys.get(keyOf(entry)) ?? entry.pinOrderKey, + })), + ).map(keyOf); + + return { plan, assignments, actualOrder, targetOrder }; + }; + + it("excludes cached capable offline rows from both writable sets", () => { + const writable = membership(); + + expect([...writable.draggableThreadKeys]).toEqual([keyOf(online)]); + expect([...writable.activeReorderableThreadKeys]).toEqual([keyOf(online)]); + expect(serverConfigs.has(offlineEnvironmentId)).toBe(true); + }); + + it("updates membership through disconnect, reconnect, and missing configuration", () => { + expect(membership([]).draggableThreadKeys.size).toBe(0); + const connected = membership([onlineEnvironmentId, offlineEnvironmentId]); + expect(connected.draggableThreadKeys.size).toBe(2); + expect(connected.activeReorderableThreadKeys.size).toBe(2); + + const changed = resolveSidebarReorderableThreadKeys({ + threads: [online, offline], + connectedEnvironmentIds: [onlineEnvironmentId, offlineEnvironmentId], + serverConfigs: new Map([[onlineEnvironmentId, config(onlineEnvironmentId)]]), + }); + expect([...changed.draggableThreadKeys]).toEqual([keyOf(online)]); + expect([...changed.activeReorderableThreadKeys]).toEqual([keyOf(online)]); + }); + + it.each(["active", "pinned"] as const)( + "replans a disconnected drag source in %s", + (section) => { + const bound = thread("offline", offlineEnvironmentId, "m"); + const moved = thread("online", onlineEnvironmentId, "t"); + const input = { section, rows: [bound, moved], moved, order: [moved, bound] }; + expect(planDrop(input).plan.kind).toBe( + section === "active" ? "move-active" : "reorder-pinned", + ); + + const disconnected = planDrop({ ...input, connectedEnvironmentIds: [] }); + + expect(disconnected.plan).toEqual({ kind: "none" }); + expect(disconnected.assignments).toEqual([]); + }, + ); + + it.each(["active", "pinned"] as const)("preserves a keyed offline bound in %s", (section) => { + const bound = thread("offline", offlineEnvironmentId, "m"); + const moved = thread("online", onlineEnvironmentId, "t"); + const result = planDrop({ section, rows: [bound, moved], moved, order: [moved, bound] }); + + expect(result.plan.kind).toBe(section === "active" ? "move-active" : "reorder-pinned"); + expect(result.assignments.map(({ id }) => id)).toEqual([keyOf(moved)]); + expect(result.actualOrder).toEqual(result.targetOrder); + }); + + it.each(["active", "pinned"] as const)( + "rejects rebalancing a keyed offline bound in %s", + (section) => { + const bound = thread("offline", offlineEnvironmentId, "m"); + const neighbor = thread("neighbor"); + const result = planDrop({ + section, + rows: [bound, online, neighbor], + moved: online, + order: [bound, neighbor, online], + }); + + expect(result.plan).toEqual({ kind: "none" }); + expect(result.assignments).toEqual([]); + }, + ); + + it.each(["active", "pinned"] as const)( + "rejects crossing the frozen keyless boundary in %s", + (section) => { + const bound = { + ...offline, + createdAt: + section === "active" ? "2026-03-09T12:00:00.000Z" : "2026-03-09T10:00:00.000Z", + }; + const order = section === "active" ? [online, bound] : [bound, online]; + const result = planDrop({ section, rows: [bound, online], moved: online, order }); + + expect(result.plan).toEqual({ kind: "none" }); + expect(result.assignments).toEqual([]); + }, + ); + + it.each(["active", "pinned"] as const)( + "allows realizable moves beside a frozen keyless row in %s", + (section) => { + const neighbor = thread("neighbor"); + const order = + section === "active" ? [offline, neighbor, online] : [online, neighbor, offline]; + const result = planDrop({ + section, + rows: [offline, online, neighbor], + moved: online, + order, + }); + + expect(result.plan.kind).toBe(section === "active" ? "move-active" : "reorder-pinned"); + expect(result.assignments.length).toBeGreaterThan(0); + expect(result.assignments.every(({ id }) => id !== keyOf(offline))).toBe(true); + expect(result.actualOrder).toEqual(result.targetOrder); + }, + ); + + it.each(["active", "pinned"] as const)( + "checks fallback assignments to other rows at the %s boundary", + (section) => { + const neighbor = thread("neighbor"); + const order = + section === "active" ? [neighbor, offline, online] : [online, offline, neighbor]; + const result = planDrop({ + section, + rows: [offline, online, neighbor], + moved: online, + order, + }); + + expect(result.plan).toEqual({ kind: "none" }); + }, + ); + + it.each(["active", "pinned"] as const)( + "rejects impossible pin/unpin placement across the %s boundary", + (section) => { + const order = section === "active" ? [online, offline] : [offline, online]; + const result = planDrop({ + section, + rows: [offline, online], + moved: online, + order, + from: section === "active" ? "pinned" : "active", + }); + + expect(result.plan).toEqual({ kind: "none" }); + }, + ); + }); + it("only changes lifecycle when the inbox is time-ordered", () => { const base = { pinnedOrder: ["p1"], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 316a81e5fb6b8..14c281cf68d6e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,6 @@ import { type SidebarInboxThread as ThreadStatusInput } from "./sidebar/organization.logic"; +import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; @@ -17,7 +19,12 @@ import { import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; import { hasUnseenCompletion as hasUnseenTurnCompletion } from "@t3tools/client-runtime/state/attention-inbox"; -import { type ContextMenuItem, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { + isListedThreadLineage, + type ContextMenuItem, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarTabSortDirection, @@ -346,6 +353,32 @@ export function sidebarThreadKeyAtY(list: HTMLElement, y: number): string | null return key; } +export function resolveSidebarReorderableThreadKeys(input: { + readonly threads: ReadonlyArray>; + readonly serverConfigs: ReadonlyMap>; + readonly connectedEnvironmentIds: readonly EnvironmentId[]; +}) { + const connectedEnvironmentIds = new Set(input.connectedEnvironmentIds); + const draggableThreadKeys = new Set(); + const activeReorderableThreadKeys = new Set(); + + for (const thread of input.threads) { + // Config is cached for offline rendering, so its presence does not make a row writable. + if ( + !connectedEnvironmentIds.has(thread.environmentId) || + !input.serverConfigs.has(thread.environmentId) + ) { + continue; + } + + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + activeReorderableThreadKeys.add(threadKey); + draggableThreadKeys.add(threadKey); + } + + return { draggableThreadKeys, activeReorderableThreadKeys }; +} + export function planSidebarThreadDrop(input: { readonly activeKey: string; readonly activeSection: SidebarSection; @@ -390,7 +423,28 @@ export function planSidebarThreadDrop(input: { keysById, movedId: activeKey, }); - return assignments.every(({ id }) => writable.has(id)) ? assignments : null; + if (assignments.some(({ id }) => !writable.has(id))) return null; + + // Keyless Active rows sort before keyed rows; keyless Pinned rows sort after them. + // Materializing any row across a frozen keyless boundary cannot persist that position. + const assignedIds = new Set(assignments.map(({ id }) => id)); + let hasFrozenKeylessBefore = false; + let hasAssignedBefore = false; + for (const id of order) { + const isFrozenKeyless = !writable.has(id) && keysById.get(id) == null; + const isAssigned = assignedIds.has(id); + if ( + (target.section === "active" && isFrozenKeyless && hasAssignedBefore) || + (target.section === "pinned" && isAssigned && hasFrozenKeylessBefore) + ) { + return null; + } + + hasFrozenKeylessBefore ||= isFrozenKeyless; + hasAssignedBefore ||= isAssigned; + } + + return assignments; }; switch (target.section) { case "active": { @@ -578,12 +632,12 @@ export async function archiveSelectedThreadEntries< return { archivedThreadKeys, mutationFailure: null, followupFailures }; } -export function isSidebarSubagentThread(thread: Pick): boolean { - return thread.lineage.relationshipToParent === "subagent"; +export function isSidebarHiddenThread(thread: Pick): boolean { + return !isListedThreadLineage(thread.lineage); } -// Subagent threads have no sidebar row, so an open subagent is represented by -// its nearest non-subagent ancestor. A broken chain (missing parent, cycle, +// Subagent and side-chat threads have no sidebar row, so an open one is represented by +// its nearest listed ancestor. A broken chain (missing parent, cycle, // other environment) resolves to the thread itself, which then matches no row. export function resolveSidebarRowThreadId( threadId: ThreadId, @@ -594,7 +648,7 @@ export function resolveSidebarRowThreadId( threads.find((thread) => thread.id === id && thread.environmentId === environmentId); const visited = new Set(); let thread = findThread(threadId); - while (thread !== undefined && isSidebarSubagentThread(thread)) { + while (thread !== undefined && isSidebarHiddenThread(thread)) { if (thread.lineage.parentThreadId === null || visited.has(thread.id)) return threadId; visited.add(thread.id); thread = findThread(thread.lineage.parentThreadId); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index 28e94d17cc284..6a7ca9361f9b1 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -262,3 +262,73 @@ it("holds a completion through a continuation gap and notifies once it settles", await vi.advanceTimersByTimeAsync(1); expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); }); + +it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( + "notifies for offline completion and %s once across reconnects", + async (flag) => { + await using app = setup(); + const asking = { id: "asking", hasPendingApprovals: true }; + const offline = { id: "offline", [flag]: true }; + const done = { + id: "done", + latestTurn: { + turnId: "done-turn", + state: "completed", + completedAt: "2026-09-13T07:00:00.000Z", + }, + }; + state.shells.set("one", shell({ id: "working" }, { id: "asking" }, { id: "offline" }, done)); + app.start(); + using _timers = fakeTimers(); + state.shells.set("one", shell({ id: "working" }, asking, { id: "offline" }, done)); + state.shells.set("one", { + ...shell({ id: "working" }, asking, { id: "offline" }, done), + status: "cached", + }); + + const completed = { + id: "working", + latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" }, + }; + state.shells.set("one", { + ...shell(completed, asking, offline, done), + status: "synchronizing", + }); + state.shells.set("one", shell(completed, asking, offline, done)); + + expect(TestNotification.sent.map(({ title }) => title)).toEqual([ + "Approval needed", + flag === "hasPendingApprovals" ? "Approval needed" : "Input needed", + ]); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + state.shells.set("one", { ...shell(completed, asking, offline, done), status: "cached" }); + state.shells.set("one", shell(completed, asking, offline, done)); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent.map(({ title }) => title)).toEqual([ + "Approval needed", + flag === "hasPendingApprovals" ? "Approval needed" : "Input needed", + "Thread completed", + ]); + }, +); + +it.each([false, true])( + "rechecks a pending completion after reconnect (continued: %s)", + async (continued) => { + await using app = setup(); + app.start(); + using _timers = fakeTimers(); + const completed = shell({ + latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" }, + }); + state.shells.set("one", completed); + state.shells.set("one", { ...completed, status: "cached" }); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent).toEqual([]); + state.shells.set("one", continued ? shell() : completed); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent.map(({ title }) => title)).toEqual( + continued ? [] : ["Thread completed"], + ); + }, +); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx index 98c80f52ad1fc..2a68c35b40cbc 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx @@ -1,8 +1,10 @@ +import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; +import { fakeTimers } from "../../test/render"; import type { ClientSettings } from "@t3tools/contracts/settings"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { Atom, AtomRegistry } from "effect/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { EnvironmentId } from "@t3tools/contracts"; import type { AnyRouter } from "@tanstack/react-router"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -22,7 +24,7 @@ const state = vi.hoisted(() => ({ turnError: false, limited: false, subagent: false, - background: [] as Array<{ taskId: string; kind: "command" | "monitor" }>, + background: [] as Array<{ taskId: string; kind: "command" | "monitor"; awaited?: boolean }>, mutedEvents: [] as ClientSettings["mutedNotificationEvents"], mutedProjects: [] as ClientSettings["mutedNotificationProjects"], conflicting: false, @@ -145,7 +147,11 @@ vi.mock(import("../state/shell"), async (original) => { const actual = await original(); return { ...actual, - environmentShell: { ...actual.environmentShell, stateValueAtom: (() => shellAtom) as never }, + environmentShell: { + ...actual.environmentShell, + stateValueAtom: (() => shellAtom) as never, + stateAtom: (() => Atom.make((get) => AsyncResult.success(get(shellAtom)))) as never, + }, }; }); vi.mock(import("../state/presentation"), async (original) => { @@ -206,10 +212,12 @@ async function render() { async function complete() { state.completedAt = "2026-09-13T10:00:00.000Z"; await render(); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); } function setup() { vi.clearAllMocks(); + const timers = fakeTimers(); Object.assign(state, { mode: "off", inApp: true, @@ -244,6 +252,7 @@ function setup() { stop?.(); registry.dispose(); settingsListeners.clear(); + timers[Symbol.dispose](); vi.restoreAllMocks(); vi.unstubAllGlobals(); }, @@ -342,17 +351,19 @@ describe("thread notifications", () => { body: "Fix the login form", tag: expect.any(String), silent: true, + requireInteraction: false, }); }); - it("alerts when only a dev server is left running, not while a monitor can wake the agent", async () => { + it("alerts when only a dev server is left running, not while the agent awaits a monitor", async () => { using _fixture = setup(); await render(); - state.background = [{ taskId: "watch", kind: "monitor" }]; + state.background = [{ taskId: "watch", kind: "monitor", awaited: true }]; await complete(); expect(state.add).not.toHaveBeenCalled(); state.background = [{ taskId: "dev", kind: "command" }]; await render(); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); expect(state.add).toHaveBeenCalledTimes(1); expect(state.add).toHaveBeenLastCalledWith( expect.objectContaining({ title: "Thread completed" }), @@ -456,13 +467,14 @@ describe("thread notifications", () => { body: "Fix the login form", tag: expect.any(String), silent: true, + requireInteraction: false, }); }); it("reveals the desktop window and opens the thread when a system alert is clicked", async () => { using _fixture = setup(); const revealWindow = vi.fn(() => Promise.resolve()); - vi.stubGlobal("desktopBridge", { revealWindow }); + vi.stubGlobal("desktopBridge", { revealWindow, onNotificationBadgeClear: () => () => {} }); state.mode = "notifications"; state.focused = false; await render(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 77edccfbe7741..fdbfb7ae1f49d 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -69,6 +69,9 @@ export function createThreadNotificationGate( for (const key of handled.keys()) if (!keys.has(key)) handled.delete(key); for (const key of lastSettled.keys()) if (!keys.has(key)) lastSettled.delete(key); }, + cancelPending(): void { + for (const key of pending.keys()) cancel(key); + }, clear(): void { for (const key of pending.keys()) cancel(key); handled.clear(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 34c71c2e34186..56d169ff943bf 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -55,6 +55,7 @@ interface NotificationState extends ThreadNotificationObservation { interface WatchedEnvironment { unsubscribe: () => void; previous: Map; + reconnecting: boolean; readonly gate: ReturnType>; } @@ -231,7 +232,11 @@ export function startThreadNotifications( } const prior = previous.get(rawThread.id); const subagentRequest = subagentRequests.get(rawThread.id) ?? null; - if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) { + if ( + !entry.reconnecting && + prior?.raw === rawThread && + prior.subagentRequest === subagentRequest + ) { next.set(rawThread.id, prior); continue; } @@ -261,6 +266,7 @@ export function startThreadNotifications( next.set(thread.id, state); changed.push([prior, state]); } + entry.reconnecting = false; entry.previous = next; entry.gate.retain(new Set(next.keys())); for (const [prior, state] of changed) { @@ -307,6 +313,7 @@ export function startThreadNotifications( const watchEnvironment = (environmentId: EnvironmentId): WatchedEnvironment => { const entry: WatchedEnvironment = { previous: new Map(), + reconnecting: false, unsubscribe: () => {}, gate: createThreadNotificationGate((threadId, kind) => { const state = entry.previous.get(threadId); @@ -325,8 +332,8 @@ export function startThreadNotifications( observeThreads(environmentId, entry, threads); return; } - entry.gate.clear(); - entry.previous = new Map(); + entry.gate.cancelPending(); + entry.reconnecting = true; }, { immediate: true }, ); diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx new file mode 100644 index 0000000000000..a99434f6659c6 --- /dev/null +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -0,0 +1,129 @@ +import { EMPTY_TERMINAL_SESSION_STATE } from "@t3tools/client-runtime/state/terminal"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { act, fakeTimers, render } from "../../test/render"; +import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; + +const fixture = vi.hoisted(() => ({ + statusByThreadId: new Map(), + pendingSurfaces: [] as Array<() => void>, +})); + +// Every surface method is a no-op; `then` stays undefined so awaiting the surface resolves to it. +const fakeSurface: object = new Proxy( + { input: document.createElement("textarea") }, + { + get: (target, key) => + key === "then" ? undefined : key in target ? Reflect.get(target, key) : () => undefined, + }, +); +vi.mock("~/terminal/ghostty/surface", () => ({ + GhosttyTerminalSurface: { + create: () => + new Promise((resolve) => { + fixture.pendingSurfaces.push(() => resolve(fakeSurface)); + }), + }, +})); +vi.mock("../state/terminalSessions", async () => { + const Atom = await import("effect/reactivity/Atom"); + const sessions = Atom.family((_environmentId: EnvironmentId) => + Atom.family((threadId: ThreadId) => + Atom.make({ + ...EMPTY_TERMINAL_SESSION_STATE, + status: fixture.statusByThreadId.get(threadId) ?? "closed", + version: 1, + }), + ), + ); + return { + attachedTerminalSessionAtom: (environmentId: EnvironmentId, terminal: { threadId: ThreadId }) => + sessions(environmentId)(terminal.threadId), + }; +}); +vi.mock("../state/session", () => ({ + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: () => async () => ({ _tag: "Success" }), +})); +// The commands are replaced above; importing their runtime would connect a client. +vi.mock("../state/server", async () => { + const Atom = await import("effect/reactivity/Atom"); + const config = Atom.make(null); + return { serverEnvironment: { configValueAtom: () => config } }; +}); +vi.mock("../state/terminal", () => ({ terminalEnvironment: { write: {}, resize: {} } })); +vi.mock("../state/preview", () => ({ previewEnvironment: { open: {} } })); +vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} })); +vi.mock("./preview/openTerminalLinkInPreview", () => ({ openTerminalLinkInPreview: () => {} })); +vi.mock("~/localApi", () => ({ readLocalApi: () => null })); +vi.mock("../hooks/useSettings", async () => { + const { DEFAULT_CLIENT_SETTINGS } = await import("@t3tools/contracts/settings"); + return { + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => T) => + select(DEFAULT_CLIENT_SETTINGS), + }; +}); + +function Drawer(props: { + threadRef: ScopedThreadRef; + onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; +}) { + return ( + {}} + onSplitTerminalVertical={() => {}} + onNewTerminal={() => {}} + onActiveTerminalChange={() => {}} + onCloseTerminal={props.onCloseTerminal} + onHeightChange={() => {}} + onAddTerminalContext={() => {}} + keybindings={[]} + /> + ); +} + +function setup() { + const original: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), + }; + const other: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-b"), + threadId: ThreadId.make("thread-b"), + }; + fixture.statusByThreadId = new Map([ + [original.threadId, "exited"], + [other.threadId, "running"], + ]); + fixture.pendingSurfaces = []; + return { original, other }; +} + +it("closes an exited terminal on its own thread after navigating to one reusing its ID", async () => { + const { original, other } = setup(); + const onCloseTerminal = vi.fn(); + await using screen = await render( + , + ); + using _timers = fakeTimers(); + // The exit is seen once the surface loads, and its close waits for the next task. + await act(async () => { + fixture.pendingSurfaces.shift()?.(); + }); + await screen.rerender(); + await act(async () => { + vi.runOnlyPendingTimers(); + }); + expect(onCloseTerminal.mock.calls).toEqual([["term-1", original]]); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 5d0203e0eb86c..842b0347a1bae 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -189,7 +189,13 @@ describe("terminal selection actions", () => { }); it("retains visible output and selection until a replacement subscription receives its snapshot", () => { - const terminal = { resetAndWrite: vi.fn(), write: vi.fn(), clearSelection: vi.fn() }; + const terminal = { + resetAndWrite: vi.fn(), + write: vi.fn(), + getSelection: vi.fn(() => "selected"), + getSelectionCellCount: vi.fn(() => 8), + clearSelection: vi.fn(), + }; const snapshot = { threadId: "thread", terminalId: "term-1", @@ -239,7 +245,13 @@ it("retains visible output and selection until a replacement subscription receiv }); it("reports an attach failure that leaves the output unchanged", () => { - const terminal = { resetAndWrite: vi.fn(), write: vi.fn(), clearSelection: vi.fn() }; + const terminal = { + resetAndWrite: vi.fn(), + write: vi.fn(), + getSelection: vi.fn(() => "selected"), + getSelectionCellCount: vi.fn(() => 8), + clearSelection: vi.fn(), + }; const previous = { ...EMPTY_TERMINAL_BUFFER_STATE, error: null }; const failed = { ...previous, error: "Terminal attach failed" }; @@ -248,5 +260,7 @@ it("reports an attach failure that leaves the output unchanged", () => { ).toBe(INITIAL_TERMINAL_OUTPUT_CURSOR); expect(terminal.write).toHaveBeenCalledWith("\r\n[terminal] Terminal attach failed\r\n"); expect(terminal.resetAndWrite).not.toHaveBeenCalled(); + expect(terminal.getSelection).not.toHaveBeenCalled(); + expect(terminal.getSelectionCellCount).not.toHaveBeenCalled(); expect(terminal.clearSelection).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index aa98aebbb9e0e..c7e40c0a488ab 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -169,21 +169,33 @@ export function writeTerminalOutputUpdate( } let colorProbeContext: CanvasRenderingContext2D | null = null; +// Avoid formatting unbounded scrollback selections on every output update. +const MAX_COMPARED_SELECTION_CELLS = 3_000; + export function synchronizeTerminalOutput( - terminal: Pick, + terminal: Pick< + GhosttyTerminalSurface, + "resetAndWrite" | "write" | "getSelection" | "getSelectionCellCount" | "clearSelection" + >, session: Pick, cursor: TerminalOutputCursor, ): TerminalOutputCursor { if (session.version === 0) return cursor; const update = readTerminalOutputUpdate(session.output, cursor); + if (update.type === "none") return update.cursor; + const cells = terminal.getSelectionCellCount(); + const compared = cells <= MAX_COMPARED_SELECTION_CELLS; + const selection = cells > 0 && compared ? terminal.getSelection() : ""; writeTerminalOutputUpdate(terminal, update); - terminal.clearSelection(); + if (!compared || (cells > 0 && terminal.getSelection() !== selection)) { + terminal.clearSelection(); + } return update.cursor; } /** Writes what changed between two session notifications: new output, then a new error. */ export function synchronizeTerminalSession( - terminal: Pick, + terminal: Parameters[0], previous: Pick, session: Pick, cursor: TerminalOutputCursor, @@ -697,6 +709,7 @@ function TerminalSessionViewport({ void terminalRef.current?.setFont(terminalFontOptions(next)); }, [terminalFontFamily, terminalFontSize, terminalLineHeight]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies cursor preferences to the imperative terminal useEffect(() => { if (terminalCursorStyleRef.current === terminalCursorStyle) return; terminalCursorStyleRef.current = terminalCursorStyle; @@ -849,11 +862,14 @@ function TerminalSessionViewport({ }; }; - const addSelectionToChat = (selection: TerminalContextSelection) => { - // Read here rather than with the selection: only an added excerpt needs its colors. + const addSelectionToChat = (clipboardText: string) => { + // Output keeps moving the selected rows while a menu is open, so take + // the line numbers now, and only if the same text is still selected. + const current = readSelectionAction(); + if (current?.clipboardText !== clipboardText) return; const styledText = keepTerminalContextStyledText(terminalRef.current?.getStyledSelection()); handleAddTerminalContext( - styledText === undefined ? selection : { ...selection, styledText }, + styledText === undefined ? current.selection : { ...current.selection, styledText }, ); terminalRef.current?.clearSelection(); terminalRef.current?.focus(); @@ -927,7 +943,7 @@ function TerminalSessionViewport({ switch (clicked) { case "add-to-chat": if (selectionAction && canAddSelectionToChat()) { - addSelectionToChat(selectionAction.selection); + addSelectionToChat(selectionAction.clipboardText); } return; case "copy": @@ -977,7 +993,7 @@ function TerminalSessionViewport({ } switch (clicked) { case "add-to-chat": - if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection); + if (canAddSelectionToChat()) addSelectionToChat(nextAction.clipboardText); return; case "copy": await copySelection(nextAction.clipboardText, requestId); @@ -1241,7 +1257,7 @@ interface ThreadTerminalDrawerProps { newShortcutLabel?: string | undefined; closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; + onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; keybindings: ResolvedKeybindingsConfig; @@ -1314,7 +1330,7 @@ function TerminalSplitPanes({ terminalLabelById: ReadonlyMap; resolveLaunchLocation: (terminalId: string) => TerminalLaunchLocation; onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; + onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; advancedTypography: boolean; threadRef: ScopedThreadRef; threadId: ThreadId; @@ -1365,7 +1381,7 @@ function TerminalSplitPanes({ {...(terminalLaunchLocation.runtimeEnv ? { runtimeEnv: terminalLaunchLocation.runtimeEnv } : {})} - onSessionExited={() => onCloseTerminal(terminalId)} + onSessionExited={() => onCloseTerminal(terminalId, threadRef)} onAddTerminalContext={onAddTerminalContext} visible={visible} keybindings={keybindings} @@ -1689,7 +1705,7 @@ export default function ThreadTerminalDrawer({ const label = terminalLabelById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { if (confirmed && readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) { - onCloseTerminal(terminalId); + onCloseTerminal(terminalId, threadRef); } }); }; diff --git a/apps/web/src/components/auth/ConnectAgentSurface.tsx b/apps/web/src/components/auth/ConnectAgentSurface.tsx index fae971b373163..fab38ccefad90 100644 --- a/apps/web/src/components/auth/ConnectAgentSurface.tsx +++ b/apps/web/src/components/auth/ConnectAgentSurface.tsx @@ -3,7 +3,7 @@ import { type AuthMcpApprovalDetails, AuthMcpApprovalError, AuthMcpClientAccess, - type AuthMcpAuthorizationRequest, + AuthMcpAuthorizationRequest, } from "@t3tools/contracts"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { isLoopbackHost } from "@t3tools/shared/preview"; @@ -76,8 +76,10 @@ function runApproval
( ).catch((): Answer => ({ kind: "error", message: UNREACHABLE })); } +const decodeAuthorizationRequest = Schema.decodeSync(AuthMcpAuthorizationRequest); + function readAuthorizationRequest(): AuthMcpAuthorizationRequest { - return Object.fromEntries(new URL(window.location.href).searchParams); + return decodeAuthorizationRequest(Object.fromEntries(new URL(window.location.href).searchParams)); } /** Sessions can grant only scopes they hold. */ diff --git a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx index bc6fe2ae4b629..914d0d0c1aba3 100644 --- a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx +++ b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx @@ -6,7 +6,7 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { QuoteIcon, XIcon } from "lucide-react"; -import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; +import { use, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { captureAssistantTextSelection, @@ -98,6 +98,7 @@ function wholeItemIds(citation: AssistantCitation | null, partialItemId?: string .map((segment) => segment.id) .filter((id) => id !== partialItemId); } +import { BackgroundThreadContext } from "./backgroundThread"; export function AssistantSelectionToolbar({ viewport, @@ -127,6 +128,7 @@ export function AssistantSelectionToolbar({ const actionsRef = useRef | null>(null); const rowSelectionActive = rowSelection !== null; const rowSelectionActiveRef = useRef(rowSelectionActive); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- exposes selection state to native pointer handlers useLayoutEffect(() => { rowSelectionActiveRef.current = rowSelectionActive; }, [rowSelectionActive]); @@ -179,6 +181,7 @@ export function AssistantSelectionToolbar({ }, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to native dismissal events for row selection useEffect(() => { if (!viewport || !rowSelectionActive) return; const clear = (event: Event) => { @@ -198,6 +201,7 @@ export function AssistantSelectionToolbar({ }; }, [rowSelectionActive, viewport]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- owns the CSS highlight registry entry for the selected range useEffect(() => { const range = rowQuote?.range; const registry = typeof CSS !== "undefined" ? CSS.highlights : undefined; @@ -211,6 +215,7 @@ export function AssistantSelectionToolbar({ }; }, [rowQuote]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to native pointer events on the transcript viewport useEffect(() => { if (!viewport) return; // Tool rows toggle on click and are not selectable, so shift-click picks them instead. @@ -250,6 +255,7 @@ export function AssistantSelectionToolbar({ viewport.removeEventListener("click", pick, true); }; }, [viewport]); + const backgroundThread = use(BackgroundThreadContext); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the toolbar DOM node and clamps its position inside the window before paint useLayoutEffect(() => { @@ -342,7 +348,8 @@ export function AssistantSelectionToolbar({ event.stopPropagation(); toolbar.focus({ preventScroll: true }); }; - document.addEventListener("keydown", focusActions, true); + // Tab belongs to the foreground chat; a background timeline only follows its selection. + if (!backgroundThread) document.addEventListener("keydown", focusActions, true); document.addEventListener("selectionchange", actions.selectionChanged); return () => { document.removeEventListener("keydown", focusActions, true); @@ -350,7 +357,7 @@ export function AssistantSelectionToolbar({ actions.dispose(); actionsRef.current = null; }; - }, [threadRef, viewport]); + }, [backgroundThread, threadRef, viewport]); const pill = rowSelection && rowCitation ? ( diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 868484f37b474..6b49b81401f33 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -1,3 +1,4 @@ +import { useSyncLayout } from "@legendapp/list/react"; import { type OrchestrationV2CheckpointFileTotals, type RunId } from "@t3tools/contracts"; import { type MouseEvent, type ReactNode, memo, useLayoutEffect, useRef, useState } from "react"; import { type TurnDiffFileChange } from "../../types"; @@ -45,6 +46,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff, onFileContextMenu, } = props; + const syncTimelineLayout = useSyncLayout(); const hiddenFileCount = totals.count - files.length; const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); @@ -83,7 +85,10 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" } data-scroll-anchor-ignore - onClick={onToggleAllDirectories} + onClick={() => { + onToggleAllDirectories(); + syncTimelineLayout(); + }} /> } > @@ -157,6 +162,7 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { key: string | null; overrides: Record; }>({ key: null, overrides: {} }); + const syncTimelineLayout = useSyncLayout(); const treeNodes = buildTurnDiffTree(files); const directoryPathsKey = collectDirectoryPaths(treeNodes).join("\u0000"); const hasDirectoryNodes = directoryPathsKey.length > 0; @@ -177,6 +183,8 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { }, }; }); + // Measure before paint instead of waiting for LegendList's deferred shrink measurement. + syncTimelineLayout(); }; const rows = collectExpandedRows(treeNodes, expandedDirectories, allDirectoriesExpanded); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 92e931445a720..b6371e5e9d037 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3,7 +3,6 @@ import { type ComposerFileAttachment, type ComposerImageAttachment, type DraftId, - type PersistedComposerImageAttachment, composerFileNeedsReattach, composerDraftHasUserContent, composerTargetKey, @@ -97,6 +96,7 @@ import { useThreadTabContextRecords } from "~/threadTabContextStore"; import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { insertAttachmentReferencesForDraft } from "./composerAttachmentReferences"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; +import { BackgroundThreadContext } from "./backgroundThread"; import { resolveCompatibleRuntimeMode, runtimeModeConfig, @@ -124,6 +124,7 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model" import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { memo, + use, useEffect, useEffectEvent, useId, @@ -151,7 +152,6 @@ import { DEFAULT_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { deriveComposerSendState, getAntigravitySendBlockReason, - readFileAsDataUrl, resolveComposerInteractionMode, threadShellHasStarted, } from "../ChatView.logic"; @@ -159,9 +159,11 @@ import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, } from "./composerMentionDrag"; +import { serializeComposerImages } from "./composerImagePersistence"; import { isInsideCollapsedComposerControls, isInsideRestingComposerControlScope, + markOpenToEffort, markOpenToPermissions, } from "./composerEventScope"; @@ -455,6 +457,8 @@ export interface ChatComposerProps { keepFullHistory: boolean; onToggleKeepFullHistory: () => void; onUsageLimitsCommand?: (() => void) | undefined; + sideChatAvailable?: boolean; + sideChatDisabledReason?: string | null; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -1166,14 +1170,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isRevertingCheckpointRef.current = isRevertingCheckpoint; }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown, blur and the desktop paste-as-text event to arm plain-text paste + const backgroundThread = use(BackgroundThreadContext); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to keydown, window blur and the desktop paste-as-text event to arm plain-text paste useEffect(() => { + // A background composer listens on its own form, so it never hears the main composer's keys. + const target: EventTarget | null = backgroundThread ? composerFormRef.current : window; + if (target === null) return; const armPasteAsTextShortcut = () => { // Electron's menu action can precede paste; browsers deliver keydown first. pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; }; - const onKeyDown = (event: KeyboardEvent) => { + const onKeyDown = (event: Event) => { if ( + event instanceof KeyboardEvent && event.target instanceof Node && composerFormRef.current?.contains(event.target) && isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) @@ -1191,26 +1200,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeElement.closest( 'input, textarea, select, button, a[href], summary, [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], [role="button"], [role="menuitem"], [role="option"]', ) !== null; + // A background composer takes no paste-to-focus, so it arms only for its own focus. if ( (activeElement instanceof Node && composerFormRef.current?.contains(activeElement)) || - !blocksPasteToFocus + (!blocksPasteToFocus && !backgroundThread) ) { armPasteAsTextShortcut(); } }; - window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); - window.addEventListener("keydown", onKeyDown, true); - window.addEventListener("blur", onBlur); + target.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + target.addEventListener("keydown", onKeyDown, true); + // An arm lasts a second, so a background composer can skip the window blur reset. + if (!backgroundThread) window.addEventListener("blur", onBlur); return () => { - window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); - window.removeEventListener("keydown", onKeyDown, true); + target.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + target.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("blur", onBlur); }; - }, []); + }, [backgroundThread]); const composerSendState = deriveComposerSendState({ prompt, imageCount: composerImages.length + composerFiles.length, + existingAttachmentCount: editingQueuedAttachments?.length ?? 0, terminalContexts: composerTerminalContexts, elementContextCount: composerPreviewAnnotations.length + composerReviewComments.length, }); @@ -1287,6 +1299,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pullRequestRepository, threadPullRequests, planModeUiEnabled, + sideChatAvailable: props.sideChatAvailable === true, + sideChatDisabledReason: props.sideChatDisabledReason ?? null, compactSlashCommandAvailable, selectedProvider, selectedProviderSkills, @@ -1542,6 +1556,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerContextRecords, composerImages, composerFilesRef, + promptRef, pendingThreadImportsRef, addComposerFilesToDraft, }); @@ -1707,31 +1722,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) getComposerDraft(attachmentDraftTarget)?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); - const existingPersistedById = new Map( - currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), - ); - const stagedAttachmentById = new Map(); - await Promise.all( - composerImages.map(async (image) => { - try { - const dataUrl = await readFileAsDataUrl(image.file); - stagedAttachmentById.set(image.id, { - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl, - ...(image.source ? { source: image.source } : {}), - }); - } catch { - const existingPersisted = existingPersistedById.get(image.id); - if (existingPersisted) { - stagedAttachmentById.set(image.id, existingPersisted); - } - } - }), + const serialized = await serializeComposerImages( + composerImages, + currentPersistedAttachments, ); - const serialized = Array.from(stagedAttachmentById.values()); if (cancelled) return; syncComposerDraftPersistedAttachments(attachmentDraftTarget, serialized); } catch { @@ -2164,6 +2158,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.command === "side" || item.command === "btw") { + if (props.sideChatDisabledReason) return; + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + `/${item.command} `, + { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd) }, + ); + if (applied) setComposerHighlightedItemId(null); + return; + } if (!planModeUiEnabled) return; void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { @@ -3250,15 +3255,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) direction, }); if (!change) return; - if (change.modelOptions) { - useComposerDraftStore - .getState() - .setProviderModelOptions(composerDraftTarget, selectedProvider, change.modelOptions, { - instanceId: selectedInstanceId, - model: selectedModel, - persistSticky: true, - }); - } + useComposerDraftStore + .getState() + .setProviderModelOptions(composerDraftTarget, selectedProvider, change.modelOptions, { + instanceId: selectedInstanceId, + model: selectedModel, + persistSticky: true, + }); if (change.prompt !== promptRef.current) { applyPromptReplacementStable(0, promptRef.current.length, change.prompt, { expandedCursorAfterReplace: change.cursor, @@ -3290,6 +3293,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!trigger) return; trigger.focus({ preventScroll: true }); if (command === "composer.mode") markOpenToPermissions(trigger); + if (command === "composer.effort") markOpenToEffort(trigger); trigger.click(); }, compactContext: compactThreadContextStable, diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 9d472d4377d22..7ac78eaf1e626 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -16,11 +16,11 @@ import { ComposerPermissionsSection, type RuntimeModeOption } from "./ComposerFo import type { ProviderInstanceEntry } from "../../providerInstances"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { + traitsMenuContent?: ReactNode; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; runtimeModeOptions: ReadonlyArray; showInteractionModeToggle: boolean; - traitsMenuContent?: ReactNode; showPermissions?: boolean; accountMenu?: { activeInstanceId: ProviderInstanceId; diff --git a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx index d3620f539cdd2..8affdda477974 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx @@ -1,16 +1,19 @@ import "../../index.css"; +import { render } from "../../../test/render"; import { InfoIcon } from "lucide-react"; import { render as renderScreen } from "../../../test/render"; import { RunId } from "@t3tools/contracts"; import { act } from "../../../test/render"; -import { expect, it, vi } from "vite-plus/test"; + import { page, userEvent } from "vite-plus/test/browser"; +import type { ReactNode } from "react"; +import { expect, it, vi } from "vite-plus/test"; import { Button, InlineButton } from "../ui/button"; import { ComposerBanner } from "./ComposerBanner"; -import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; + import { usageLimitRecoveryBannerItem } from "./UsageLimitRecoveryBanner"; async function setup() { @@ -196,30 +199,37 @@ it("offers one details button when the usage-limit title is clipped", async () = screen.rerender( {}, - timestampFormat: "locale", - onResumeNow: async () => {}, - continueElsewhere: null, - }), + { + ...usageLimitRecoveryBannerItem({ + runId, + resetAt, + stoppedAt: "2026-10-20T21:00:00.000Z", + snoozedUntil: null, + recovery: null, + onChange: async () => {}, + timestampFormat: "locale", + onResumeNow: async () => {}, + continueElsewhere: null, + }), + title: "Usage limit reached for the current provider account", + }, ]} />, ), ); - const title = container.querySelector(".truncate.font-medium")!; + const title = container.querySelector( + ".truncate.font-medium [data-notice-text], .truncate.font-medium:not(:has([data-notice-text]))", + )!; expect(title.scrollWidth).toBeGreaterThan(title.clientWidth); expect(container.querySelectorAll('[aria-label$="details"]')).toHaveLength(1); await userEvent.hover(page.getByRole("button", { name: "Show notice details", exact: true })); const popup = page.getByRole("dialog", { name: "Notice details", exact: true }); await expect.element(popup).toBeVisible(); - expect(popup.element().textContent).toMatch(/^Usage limit reachedResets /); + expect(popup.element().textContent).toMatch( + /^Usage limit reached for the current provider accountResets /, + ); }); it.each([320, 800])( @@ -309,6 +319,11 @@ it.each([320, 800])( expect(openThread).toHaveBeenLastCalledWith(12); }, ); +import { + ComposerBannerStack, + NoticeText, + type ComposerBannerStackItem, +} from "./ComposerBannerStack"; function notice(title: string, group?: ComposerBannerStackItem["group"]): ComposerBannerStackItem { return { id: title, variant: "default", icon: , title, ...(group ? { group } : {}) }; @@ -336,3 +351,88 @@ it("leaves the next attachment its own top edge when there are no notices", asyn expect(surface.borderTopWidth).toBe("1px"); expect(surface.borderTopLeftRadius).not.toBe("0px"); }); + +function noticeRow(width: string, children: ReactNode) { + return ( +
+ + {children} + +
+ ); +} + +it("leaves out the details button when only a link's hit area reaches past the text", async () => { + // Phones lay out in device pixels, so a range can measure one float32 step (2^-17 px at + // this size) wider than its element. Headless Chromium's rects agree exactly. + const rangeRect = Range.prototype.getBoundingClientRect; + using _rangeRect = vi + .spyOn(Range.prototype, "getBoundingClientRect") + .mockImplementation(function (this: Range) { + const rect = rangeRect.call(this); + return new DOMRect(rect.x, rect.y, rect.width + 2 ** -17, rect.height); + }); + // The flicker is a details button that mounts and then unmounts, so record every insertion. + const inserted: Node[] = []; + using observing = new DisposableStack(); + const mutations = new MutationObserver((records) => + inserted.push(...records.flatMap((record) => [...record.addedNodes])), + ); + mutations.observe(document.body, { childList: true, subtree: true }); + observing.defer(() => mutations.disconnect()); + await using screen = await render( + noticeRow( + "20rem", + <> + Needs input{" "} + + , + ), + ); + + 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 735fb99cd6531..997c528ad8e31 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -89,6 +89,7 @@ function NoticeDetails({ ); } +const SUBPIXEL_ROUNDING = 0.001; export function NoticeText({ children, @@ -100,30 +101,56 @@ export function NoticeText({ readonly detailsLabel: string; }) { const textRef = useRef(null); - const [showDetails, setShowDetails] = useState(false); + const detailsRef = useRef(null); + 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 = () => { - const style = getComputedStyle(text); - // A narrow composer pulls the description out of the line entirely. - const hidden = style.position === "absolute"; - // Padding holds the out-of-flow details button. It is inside the text, so - // subtract it or the button would count as overflow and never leave. - const reserved = Number.parseFloat(style.paddingInlineEnd) || 0; - setShowDetails( + // 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"; + const overflowing = hidden || - [text, ...text.querySelectorAll("*")].some( - (element) => - element.scrollWidth > element.clientWidth + (element === text ? reserved : 0), - ), + [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(); @@ -170,7 +197,10 @@ function NoticeCard({ density="comfortable" data-chat-composer-collapsed-controls="true" > - + {item.icon} @@ -194,6 +224,10 @@ function NoticeCard({ {item.title}
{item.description}
+ ) : descriptionBelow && item.description ? ( + + {item.description} + ) : item.description ? ( {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 allProviderAccounts = accountsForProvider({ entries: providerInstanceEntries, driverKind: selectedProvider, @@ -221,13 +170,10 @@ export function ComposerControlsBar(props: { showModeBlock, hiddenBlockCount: ownedHiddenBlockCount, hiddenBlockIds: ownedHiddenBlockIds, - traitsHidden: traitsBlockHidden, - modeHidden: modeBlockHidden, - effortOwnsModeShortcut, overflowShowsPermissions, + pickerOwnsModeShortcut, } = resolveComposerControlsOwnership({ - matchesDefault: permissionsChipHidden, - hasTraits: providerTraitsPicker !== null, + matchesDefault: accessMatchesDefault || props.permissionsShownInStrip, planModeUiEnabled, collapsed: composerControlsCollapsed, measuredHiddenBlockCount: measuredHiddenBlockCount - (showProviderAccountPicker ? 1 : 0), @@ -236,12 +182,6 @@ export function ComposerControlsBar(props: { const hiddenRestingBlockIds: ReadonlyArray = accountBlockHidden ? [...ownedHiddenBlockIds, "account"] : ownedHiddenBlockIds; - const restingProviderTraitsPicker = renderProviderTraitsPicker({ - ...providerTraitsPickerInput, - size: composerControlsCollapsed ? "xs" : "sm", - hidden: composerControlsHidden || traitsBlockHidden, - extraMenuOwnsModeShortcut: effortOwnsModeShortcut, - }); // A started thread that can fork lists every account; the ones it cannot switch to in place // open in a new tab instead. const providerAccounts = onForkModel ? allProviderAccounts : lockedProviderAccounts; @@ -276,14 +216,6 @@ export function ComposerControlsBar(props: { /> ) : null; const restingBlockDefs = [ - ...(providerTraitsPicker - ? [ - { - id: "traits", - content: restingProviderTraitsPicker, - }, - ] - : []), ...(!showModeBlock ? [] : [ @@ -296,7 +228,7 @@ export function ComposerControlsBar(props: { runtimeMode={compatibleRuntimeMode} runtimeModeOptions={compatibleRuntimeModeOptions} size={composerControlsCollapsed ? "xs" : "sm"} - hidden={composerControlsHidden || modeBlockHidden} + hidden={composerControlsHidden || ownedHiddenBlockCount > 0} accessDisplay={accessDisplay} emphasized={!accessMatchesDefault} onToggleInteractionMode={toggleInteractionMode} @@ -314,6 +246,21 @@ export function ComposerControlsBar(props: { ] : []), ]; + 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 effort = describeComposerEffort(traitsInput, props.effortDisplay, controlSize); // Its own element, so typing (which rebuilds the controls around it) leaves it cached. const composerModelPicker = ( } + footer={ + + } terminalOpen={terminalOpen} open={modelPickerOpen} instanceIndicatorBackground={ @@ -419,12 +380,9 @@ export function ComposerControlsBar(props: { interactionMode={interactionMode} runtimeMode={compatibleRuntimeMode} runtimeModeOptions={compatibleRuntimeModeOptions} - size={composerControlsCollapsed ? "xs" : "sm"} + size={controlSize} hidden={composerControlsHidden || hiddenRestingBlockIds.length === 0} - showInteractionModeToggle={planModeUiEnabled && hiddenRestingBlockIds.includes("mode")} - traitsMenuContent={ - hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined - } + showInteractionModeToggle={planModeUiEnabled && hiddenRestingBlockIds.length > 0} // Permissions join the overflow only when their normal home is in it. showPermissions={overflowShowsPermissions} {...(hiddenRestingBlockIds.includes("account") && showProviderAccountPicker diff --git a/apps/web/src/components/chat/ComposerFooterControls.tsx b/apps/web/src/components/chat/ComposerFooterControls.tsx index 520f58054f255..e5e194086cdac 100644 --- a/apps/web/src/components/chat/ComposerFooterControls.tsx +++ b/apps/web/src/components/chat/ComposerFooterControls.tsx @@ -99,7 +99,7 @@ export const ComposerFooterModeControls = memo(function ComposerFooterModeContro ) : null; - // The chip opens the same Permissions toggle used in the effort menu. + // The chip opens the same Permissions toggle used in the model picker and the overflow menu. const permissionsTooltip = `Permissions: ${runtimeModeOption.label}. ${runtimeModeOption.description}`; const permissionsChip = (
@@ -162,7 +162,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: { @@ -188,7 +188,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; @@ -217,7 +217,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 e6de6c6728bde..d7b85485c8026 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -433,7 +433,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ } >
onOpenThread(parentThreadLink.threadId)} + {...(parentThreadLink.open || parentThreadLink.threadId + ? { + onAction: + parentThreadLink.open ?? (() => onOpenThread(parentThreadLink.threadId!)), + } + : {})} trailing={ (parentThreadLink.handle ?? null) === null ? null : ( @@ -1425,7 +1443,10 @@ const ConversationTimeline = memo(function ConversationTimeline({ fullscreenAppRowId !== null || disclosureToggleSettling ? false - : isWorking && !prefersReducedMotion && settlingListIdentity === null + : isWorking && + !prefersReducedMotion && + settlingListIdentity === null && + findListReady ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH : TIMELINE_MAINTAIN_SCROLL_AT_END } @@ -1550,16 +1571,18 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ? undefined : row.kind === "turn-fold" || row.kind === "working" ? "pb-1.5" - : (row.kind === "message" && - row.message.role === "assistant" && - !row.showAssistantMeta) || - row.kind === "worktree-setup" || - row.kind === "event" || - row.kind === "attempt-fold" || - row.kind === "html-render" || - row.kind === "mcp-app" - ? "pb-2" - : "pb-4", + : row.kind === "message" && row.groupedWithNext + ? "pb-1" + : (row.kind === "message" && + row.message.role === "assistant" && + !row.showAssistantMeta) || + row.kind === "worktree-setup" || + row.kind === "event" || + row.kind === "attempt-fold" || + row.kind === "html-render" || + row.kind === "mcp-app" + ? "pb-2" + : "pb-4", (row.kind === "message" && row.message.role === "assistant") || row.kind === "assistant-meta" ? "group/assistant" diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 4afdda410575d..e87bfbf45487f 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -1,7 +1,7 @@ import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; import { memo } from "react"; import { CheckIcon, GitForkIcon, StarIcon } from "lucide-react"; -import { getDisplayModelName, getProviderRowLabel } from "./providerIconUtils"; +import { getDisplayModelName } from "./providerIconUtils"; import { ComboboxItem } from "../ui/combobox"; import { Button } from "../ui/button"; import { Badge } from "../ui/badge"; @@ -23,8 +23,11 @@ export const ModelListRow = memo(function ModelListRow(props: { acpRegistryAgentId?: string | undefined; acpRegistryIconUrl?: string | undefined; isFavorite: boolean; + /** In Favorites every row is starred, so the star shows only on hover. */ + favoriteStarOnHover?: boolean; isSelected: boolean; - showProvider: boolean; + /** Marks this instance apart from another of the same provider. */ + showInstanceBadge: boolean; showNewBadge?: boolean; unavailable?: boolean; jumpLabel?: string | null; @@ -40,11 +43,11 @@ export const ModelListRow = memo(function ModelListRow(props: { name: props.name, subProvider: props.subProvider, }; - const providerLabel = getProviderRowLabel(props.providerDisplayName, props.subProvider); const row = ( -
-
-
- {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}
@@ -148,9 +154,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 f59307546964f..f5c867c4a202c 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -20,7 +20,7 @@ import { type ReactNode, type RefObject, } from "react"; -import { ChevronRightIcon } from "lucide-react"; +import { ArchiveIcon, ChevronRightIcon } from "lucide-react"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner"; @@ -60,6 +60,7 @@ import { InlineButton } from "../ui/button"; import { isProviderInstancePickerReady, isProviderInstancePickerVisible, + shouldShowInstanceBadge, type ProviderInstanceEntry, } from "../../providerInstances"; import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; @@ -168,10 +169,9 @@ export 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; @@ -371,6 +371,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { modelRequiresFork?: (instanceId: ProviderInstanceId, model: string) => boolean; /** 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, @@ -566,31 +570,28 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onForkModel !== undefined && (!matchesLockedProvider(model) || modelRequiresFork?.(model.instanceId, model.slug) === true); 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 || selectedDriverKind === "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 lockedDisabledDriverKinds = (() => { @@ -898,7 +899,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; })(); + const badgedInstanceIds = new Set( + instanceEntries + .filter((entry) => shouldShowInstanceBadge(entry, instanceEntries)) + .map((entry) => entry.instanceId), + ); const modelListExtraData = (() => ({ + badgedInstanceIds, favoritesSet, modelJumpLabelByKey, isCollapsedModelSelected, @@ -907,7 +914,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; } @@ -960,7 +973,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 && @@ -1078,6 +1101,29 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { 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(); @@ -1115,10 +1161,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { aria-expanded={legacySection.isExpanded} className="group w-full cursor-pointer" > -
+