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

hi

", + "DaTa:text/html,

hi

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

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

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

What's Changed

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

hi

", + "javascript:alert(1)", + "about:blank", + "blob:t3code://app/0b5e2c4a", + ]) { + assert.isFalse( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "t3code://app/", + navigationUrl, + }), + navigationUrl, + ); + } + assert.isFalse( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "data:text/html,app", + navigationUrl: "data:text/html,other", + }), + ); + }); + it.effect( "opens and reopens the window without backend readiness when local execution is disabled", () => @@ -895,6 +941,39 @@ describe("DesktopWindow", () => { }), ); + it.effect("maps macOS horizontal swipes to available history navigation", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + const swipe = fakeWindow.windowListeners.get("swipe"); + assert.isDefined(swipe); + if (!swipe) return; + const history = fakeWindow.navigationHistory; + + swipe({}, "left"); + assert.equal(history.goBack.mock.calls.length, 1); + assert.equal(history.goForward.mock.calls.length, 0); + swipe({}, "right"); + assert.equal(history.goForward.mock.calls.length, 1); + + swipe({}, "up"); + swipe({}, "down"); + history.canGoBack.mockReturnValue(false); + history.canGoForward.mockReturnValue(false); + swipe({}, "left"); + swipe({}, "right"); + assert.equal(history.goBack.mock.calls.length, 1); + assert.equal(history.goForward.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("persists the current main window bounds before the window closes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -973,6 +1052,17 @@ describe("DesktopWindow", () => { if (!willNavigate) { return yield* Effect.die("will-navigate listener was not registered"); } + for (const [url, expectedPrevented] of [ + ["t3code-dev://app/threads?id=1#latest", false], + ["t3code-dev://other/", true], + ["t3code://app/", true], + ] as const) { + const preventDefault = vi.fn(); + willNavigate({ preventDefault }, url); + assert.equal(preventDefault.mock.calls.length, expectedPrevented ? 1 : 0, url); + } + assert.deepEqual(openedExternalUrls, []); + let prevented = false; willNavigate( { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index c1c0cf5446cd2..10801089841d1 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -183,12 +183,19 @@ function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } -function isSameOriginRendererNavigation(input: { +export function isSameOriginRendererNavigation(input: { readonly applicationUrl: string; readonly navigationUrl: string; }): boolean { try { - return new URL(input.applicationUrl).origin === new URL(input.navigationUrl).origin; + const applicationUrl = new URL(input.applicationUrl); + const navigationUrl = new URL(input.navigationUrl); + // Node serializes custom-scheme origins as "null", even for different hosts and schemes. + return ( + applicationUrl.host !== "" && + navigationUrl.protocol === applicationUrl.protocol && + navigationUrl.host === applicationUrl.host + ); } catch { return false; } @@ -678,6 +685,12 @@ export const make = Effect.gen(function* () { }); if (environment.platform === "darwin") { + // Mouse drivers can deliver Back/Forward as macOS swipe gestures instead of buttons. + window.on("swipe", (_event, direction) => { + const history = window.webContents.navigationHistory; + if (direction === "left" && history.canGoBack()) history.goBack(); + else if (direction === "right" && history.canGoForward()) history.goForward(); + }); window.on("enter-full-screen", () => { window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); }); diff --git a/apps/marketing/public/pfps/AtharvaIngle7.webp b/apps/marketing/public/pfps/AtharvaIngle7.webp new file mode 100644 index 0000000000000..3352a53161520 Binary files /dev/null and b/apps/marketing/public/pfps/AtharvaIngle7.webp differ diff --git a/apps/marketing/public/pfps/BennettBuhner.webp b/apps/marketing/public/pfps/BennettBuhner.webp deleted file mode 100644 index 39d5af923c51e..0000000000000 Binary files a/apps/marketing/public/pfps/BennettBuhner.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/Josikinz.webp b/apps/marketing/public/pfps/Josikinz.webp deleted file mode 100644 index 68d5825222ac0..0000000000000 Binary files a/apps/marketing/public/pfps/Josikinz.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/MichaelArnaldi.webp b/apps/marketing/public/pfps/MichaelArnaldi.webp new file mode 100644 index 0000000000000..812ceb1c2ff12 Binary files /dev/null and b/apps/marketing/public/pfps/MichaelArnaldi.webp differ diff --git a/apps/marketing/public/pfps/PavolRusnak.webp b/apps/marketing/public/pfps/PavolRusnak.webp new file mode 100644 index 0000000000000..09ed72e80f4a0 Binary files /dev/null and b/apps/marketing/public/pfps/PavolRusnak.webp differ diff --git a/apps/marketing/public/pfps/Rasmic.webp b/apps/marketing/public/pfps/Rasmic.webp new file mode 100644 index 0000000000000..e6bd6bcae1d26 Binary files /dev/null and b/apps/marketing/public/pfps/Rasmic.webp differ diff --git a/apps/marketing/public/pfps/Shay_Benshabtay.webp b/apps/marketing/public/pfps/Shay_Benshabtay.webp deleted file mode 100644 index c827f7504c44e..0000000000000 Binary files a/apps/marketing/public/pfps/Shay_Benshabtay.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/amritwt.webp b/apps/marketing/public/pfps/amritwt.webp new file mode 100644 index 0000000000000..a89bc38f5a3ab Binary files /dev/null and b/apps/marketing/public/pfps/amritwt.webp differ diff --git a/apps/marketing/public/pfps/appfactory.webp b/apps/marketing/public/pfps/appfactory.webp new file mode 100644 index 0000000000000..92ee890b71cb8 Binary files /dev/null and b/apps/marketing/public/pfps/appfactory.webp differ diff --git a/apps/marketing/public/pfps/aronprins.webp b/apps/marketing/public/pfps/aronprins.webp deleted file mode 100644 index 90db37d28f2d4..0000000000000 Binary files a/apps/marketing/public/pfps/aronprins.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/developedbyed.webp b/apps/marketing/public/pfps/developedbyed.webp deleted file mode 100644 index 8f1dfbab6dbca..0000000000000 Binary files a/apps/marketing/public/pfps/developedbyed.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/dhh.webp b/apps/marketing/public/pfps/dhh.webp new file mode 100644 index 0000000000000..1bdca7e527f0c Binary files /dev/null and b/apps/marketing/public/pfps/dhh.webp differ diff --git a/apps/marketing/public/pfps/enunomaduro.webp b/apps/marketing/public/pfps/enunomaduro.webp deleted file mode 100644 index ce054dbfb6558..0000000000000 Binary files a/apps/marketing/public/pfps/enunomaduro.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/iamkaffe.webp b/apps/marketing/public/pfps/iamkaffe.webp deleted file mode 100644 index ded6ed2cd868f..0000000000000 Binary files a/apps/marketing/public/pfps/iamkaffe.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/kostyniuk00.webp b/apps/marketing/public/pfps/kostyniuk00.webp deleted file mode 100644 index c52f0b305b9b2..0000000000000 Binary files a/apps/marketing/public/pfps/kostyniuk00.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/leodev.webp b/apps/marketing/public/pfps/leodev.webp deleted file mode 100644 index 91a47d5de9429..0000000000000 Binary files a/apps/marketing/public/pfps/leodev.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/linuz90.webp b/apps/marketing/public/pfps/linuz90.webp new file mode 100644 index 0000000000000..23bcb27f88c0a Binary files /dev/null and b/apps/marketing/public/pfps/linuz90.webp differ diff --git a/apps/marketing/public/pfps/mil000.webp b/apps/marketing/public/pfps/mil000.webp deleted file mode 100644 index 37d784b752cfb..0000000000000 Binary files a/apps/marketing/public/pfps/mil000.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/mweinbach.webp b/apps/marketing/public/pfps/mweinbach.webp new file mode 100644 index 0000000000000..f7a30f82b1f66 Binary files /dev/null and b/apps/marketing/public/pfps/mweinbach.webp differ diff --git a/apps/marketing/public/pfps/pcstyle53.webp b/apps/marketing/public/pfps/pcstyle53.webp new file mode 100644 index 0000000000000..6d17caae22d8c Binary files /dev/null and b/apps/marketing/public/pfps/pcstyle53.webp differ diff --git a/apps/marketing/public/pfps/pocarles.webp b/apps/marketing/public/pfps/pocarles.webp deleted file mode 100644 index f259df8312482..0000000000000 Binary files a/apps/marketing/public/pfps/pocarles.webp and /dev/null differ diff --git a/apps/marketing/public/pfps/serhiyhvala.webp b/apps/marketing/public/pfps/serhiyhvala.webp new file mode 100644 index 0000000000000..f299338d9cd58 Binary files /dev/null and b/apps/marketing/public/pfps/serhiyhvala.webp differ diff --git a/apps/marketing/public/pfps/szdziedzic.webp b/apps/marketing/public/pfps/szdziedzic.webp new file mode 100644 index 0000000000000..a4b193b12a0cf Binary files /dev/null and b/apps/marketing/public/pfps/szdziedzic.webp differ diff --git a/apps/marketing/public/pfps/teja2495.webp b/apps/marketing/public/pfps/teja2495.webp deleted file mode 100644 index 457901988d4ce..0000000000000 Binary files a/apps/marketing/public/pfps/teja2495.webp and /dev/null differ diff --git a/apps/marketing/src/lib/tweets.ts b/apps/marketing/src/lib/tweets.ts index 604fbec91484b..54fed029a849b 100644 --- a/apps/marketing/src/lib/tweets.ts +++ b/apps/marketing/src/lib/tweets.ts @@ -15,63 +15,64 @@ export const tweets = [ link: "https://x.com/QuinnyPig/status/2089581739887014376", }, { - handle: "tannerlinsley", - content: "I would just use T3 Code at that point and contribute back.", - link: "https://x.com/tannerlinsley/status/2035067912659341399", + handle: "dhh", + content: + "Omarchy is going to ship with an installer for T3 Code because it's a cool project that a lot of people enjoy. Squashing beefs in the process is just bonus.", + excerpt: + "Omarchy is going to ship with an installer for T3 Code because it's a cool project...", + link: "https://x.com/dhh/status/2090332565966123041", }, { - handle: "ryanrhughes", + handle: "MichaelArnaldi", content: - "I think I've finally found the one AI harness to steal me away from the terminal — @t3dotcodes.", - excerpt: "I think I've finally found the one AI harness to steal me away from the terminal", - link: "https://x.com/ryanrhughes/status/2093051703616573769", + "Since I've started using T3 Code I no longer care which harness is used, I'd actually love to get rid of all of them and just use @tim_smart 's clanka", + excerpt: "Since I've started using T3 Code I no longer care which harness is used", + link: "https://x.com/MichaelArnaldi/status/2110290227021365720", + }, + { + handle: "DavidKPiano", + content: "It's like Claude Code if they didn't vibe-code the entire thing", + link: "https://x.com/DavidKPiano/status/2054682983504719930", }, { - handle: "developedbyed", - content: "might say alpha but T3 code is pretty awesome, really fast too", - link: "https://x.com/developedbyed/status/2030627970532921605", + handle: "tannerlinsley", + content: "I would just use T3 Code at that point and contribute back.", + link: "https://x.com/tannerlinsley/status/2035067912659341399", }, { handle: "RhysSullivan", content: "T3 Code is worth giving a try if you haven’t yet\n\nIt takes what I liked so much about codex’s remote setup and improves upon it, it’s a great UX using it on both desktop and mobile\n\nAlso is just nice to have all the official harnesses in one app", - excerpt: - "It takes what I liked so much about codex’s remote setup and improves upon it, it’s a great UX using it on both desktop and mobile", + excerpt: "It takes what I liked so much about codex’s remote setup and improves upon it", link: "https://x.com/RhysSullivan/status/2089750636686643493", }, { - handle: "DavidKPiano", - content: "It's like Claude Code if they didn't vibe-code the entire thing", - link: "https://x.com/DavidKPiano/status/2054682983504719930", - }, - { - handle: "JoshuaRileyDev", + handle: "Rasmic", content: - "Well I was 2,000 miles away on holiday and able to do iOS development from just my phone, T3 Code’s remote feature is a magical experience", - excerpt: "I was 2,000 miles away on holiday and able to do iOS development from just my phone", - link: "https://x.com/JoshuaRileyDev/status/2094379365408616837", + "I can't believe my favourite gui is built by a YouTuber and a Swedish guy named Julius", + link: "https://x.com/Rasmic/status/2113063390515995066", }, { - handle: "aronprins", + handle: "AtharvaIngle7", content: - "T3 Code works better than Claude and Codex combined… and T3 actually combines then (and more!) 🤯\n\nHuge shoutout to @theo and @jullerino\n\nAmazing product fellers 🔥🙌", - excerpt: - "T3 Code works better than Claude and Codex combined… and T3 actually combines then (and more!)", - link: "https://x.com/aronprins/status/2089761738358915267", + "T3 Code is miles ahead of anything else i've tried (even the Codex app, which i haven't opened in a month fyi).", + excerpt: "T3 Code is miles ahead of anything else i've tried", + link: "https://x.com/AtharvaIngle7/status/2114267747203039682", }, { - handle: "teja2495", + handle: "appfactory", content: - "I’ve completely switched to T3 Code for all my workflows. I just switch between different subscriptions and harnesses depending on what I need.", - excerpt: "I’ve completely switched to T3 Code for all my workflows.", - link: "https://x.com/teja2495/status/2052420254991581623", + "This is a popular opinion now, but T3 Code is a pleasure to use.\n\nIt is the most sane version of an agent orchestrator that I've used: It has well thought out defaults and good performance.\n\nWell done T3 Team!", + excerpt: "It is the most sane version of an agent orchestrator that I've used", + link: "https://x.com/appfactory/status/2108498055074812393", }, { - handle: "ryanrhughes", + handle: "linuz90", content: - "Then there's the mobile app. All of my threads, on all of my environments, in my pocket. Fantastic.\n\nWhile traveling the other day, I had to use T3 Code alongside my old Claude Code + Herdr setup from 36,000ft on my phone.\n\nOn T3 Code, it felt like LAN. On the TUI, every keystroke took about 1sec to show up because it had to roundtrip to the device before I was able to actually see it. Brutal.", - excerpt: "...from 36,000ft on my phone. On T3 Code, it felt like LAN.", - link: "https://x.com/ryanrhughes/status/2093051708939059204", + "I’m at the gym and my own personal gym app misses some exercises.\n\nSo I launch a T3 Code thread and ship updates as I train.\n\nGolden age for makers or peak AI psychosis?", + excerpt: + "...gym app misses some exercises. So I launch a T3 Code thread and ship updates as I train.", + link: "https://x.com/linuz90/status/2110051011578745210", }, { handle: "ex0t1clol", @@ -79,42 +80,30 @@ export const tweets = [ link: "https://x.com/ex0t1clol/status/2054666870008021197", }, { - handle: "enunomaduro", - content: "So I tried @theo's T3 Code...", - link: "https://x.com/enunomaduro/status/2091915930141876495", + handle: "mweinbach", + content: "T3 Code with connect in just a web browser is so killer I love it so much", + link: "https://x.com/mweinbach/status/2077595624707395636", }, { - handle: "pocarles", - content: - "Only using Codex and T3 now.\n\nI thought all AI coding harnesses had roughly the same impact. Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", - excerpt: - "Using T3 Code taught me how wrong I was. The interface between you and the model changes everything.", - link: "https://x.com/pocarles/status/2054673964274758046", - }, - { - handle: "BennettBuhner", + handle: "ryanrhughes", content: - "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere! The app is great but the website is even greater, so instead of needing to SSH into a machine, T3 IS my SSH!", - excerpt: - "T3 Code is literally Codex but better; all your favorite models and harnesses, accessible anywhere.", - link: "https://x.com/BennettBuhner/status/2054667115697754387", + "I think I've finally found the one AI harness to steal me away from the terminal — @t3dotcodes.", + excerpt: "I think I've finally found the one AI harness to steal me away from the terminal", + link: "https://x.com/ryanrhughes/status/2093051703616573769", }, { - handle: "leodev", + handle: "PavolRusnak", content: - "T3 Code is probably the first coding gui that didn't suck performance wise and had a smart team behind it.", - link: "https://x.com/leodev/status/2054679746353537042", + "Super pleasantly surprised with @t3dotcodes\n\nSpent countless hours trying to get OpenClaw or Hermes Agent working for my use cases to no avail\n\nBut with t3 everything just works as expected and on my first try!", + excerpt: "...with t3 everything just works as expected and on my first try!", + link: "https://x.com/PavolRusnak/status/2114844388136267873", }, { - handle: "Shay_Benshabtay", + handle: "amritwt", content: - "T3 code is not perfect, but it's damn good. Open and fun , I can make my own fork for my uses and use the great tooling with my own needs.", - link: "https://x.com/Shay_Benshabtay/status/2054668503857156326", - }, - { - handle: "iamkaffe", - content: "T3Code was the first one to truly care about Linux users.", - link: "https://x.com/iamkaffe/status/2054675539311411280", + "I finally installed T3 Code and I am in awe about how it connects to a remote VM seamlessly with no Tailscale", + excerpt: "...I am in awe about how it connects to a remote VM seamlessly with no Tailscale", + link: "https://x.com/amritwt/status/2104046763418263999", }, { handle: "_winter_wonders", @@ -122,22 +111,32 @@ export const tweets = [ link: "https://x.com/_winter_wonders/status/2052350198764970434", }, { - handle: "kostyniuk00", + handle: "szdziedzic", content: - "I was not expecting a year ago, that my anti-AI colleagues would thank me a year later, for persuading them to try T3 Code Beta, that really helped them with organizing their workflows. \n\nFantastic product by @jullerino and @theo. Go try it out if you haven’t yet!", - excerpt: - "My anti-AI colleagues thanked me for persuading them to try T3 Code Beta. Fantastic product.", - link: "https://x.com/kostyniuk00/status/2052041388179468521", + "I became a T3 Code user recently - it’s great especially if you have many subs and many machines to manage. I recommend giving it a shot if you haven’t already 👏.", + excerpt: "...it’s great especially if you have many subs and many machines to manage.", + link: "https://x.com/szdziedzic/status/2103225721007005823", + }, + { + handle: "JoshuaRileyDev", + content: + "Well I was 2,000 miles away on holiday and able to do iOS development from just my phone, T3 Code’s remote feature is a magical experience", + excerpt: "I was 2,000 miles away on holiday and able to do iOS development from just my phone", + link: "https://x.com/JoshuaRileyDev/status/2094379365408616837", }, { - handle: "Josikinz", + handle: "pcstyle53", content: - "T3 code is better because it’s like if the codex Mac app didn’t make my computer run like shit 😋", - link: "https://x.com/Josikinz/status/2030367951694745870", + "the fact that @theo's team got the terminal in t3 code so good that you can use advanced TUIs inside it is insane to me", + excerpt: + "...got the terminal in t3 code so good that you can use advanced TUIs inside it is insane to me", + link: "https://x.com/pcstyle53/status/2080949310401757306", }, { - handle: "mil000", - content: "T3 code saved my relationship!", - link: "https://x.com/mil000/status/2030120041451246071", + handle: "serhiyhvala", + content: + "Now it’s the best app I’ve ever used. I can’t imagine my workday without it. I even cancelled my Cursor subscription. I just don’t need it anymore.\n\nIf you tried it once and bounced, give it a second chance.", + excerpt: "If you tried it once and bounced, give it a second chance.", + link: "https://x.com/serhiyhvala/status/2115375016061608018", }, ] satisfies Tweet[]; diff --git a/apps/mobile/app.config.test.ts b/apps/mobile/app.config.test.ts new file mode 100644 index 0000000000000..b8111f45587cf --- /dev/null +++ b/apps/mobile/app.config.test.ts @@ -0,0 +1,33 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; + +// Keep config parsing independent of local env files and build credentials. +vi.mock("../../scripts/lib/public-config.ts", () => ({ + loadRepoEnv: () => ({ APP_VARIANT: process.env.APP_VARIANT }), +})); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +async function loadConfig(variant: string | undefined) { + vi.resetModules(); + vi.stubEnv("APP_VARIANT", variant); + return (await import("./app.config.ts")).default; +} + +it.each(["prod", ""])("rejects APP_VARIANT=%j before selecting production IDs", async (value) => { + await expect(loadConfig(value)).rejects.toThrow( + "APP_VARIANT must be development, preview, or production.", + ); +}); + +it.each([ + [undefined, "com.t3tools.t3code"], + ["development", "com.t3tools.t3code.dev"], + ["preview", "com.t3tools.t3code.preview"], + ["production", "com.t3tools.t3code"], +])("keeps the iOS and Android identifiers for APP_VARIANT=%j", async (value, identifier) => { + const config = await loadConfig(value); + expect(config.ios?.bundleIdentifier).toBe(identifier); + expect(config.android?.package).toBe(identifier); +}); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ea885f819984f..e6d388b9a9856 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -108,8 +108,10 @@ function resolveAppVariant(value: string | undefined): AppVariant { case "preview": case "production": return value; - default: + case undefined: return "production"; + default: + throw new Error("APP_VARIANT must be development, preview, or production."); } } diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index 22fcb74a36cfb..5ff3009ef4d20 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -4,7 +4,8 @@ "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"], "appDelegateSubscribers": [ "T3WorkspaceOrientationSubscriber", - "T3MenuDismissTouchGateSubscriber" + "T3MenuDismissTouchGateSubscriber", + "T3BackgroundGraceSubscriber" ] }, "android": { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3BackgroundGraceSubscriber.swift b/apps/mobile/modules/t3-native-controls/ios/T3BackgroundGraceSubscriber.swift new file mode 100644 index 0000000000000..62eb5d86ad12e --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3BackgroundGraceSubscriber.swift @@ -0,0 +1,23 @@ +import ExpoModulesCore +import UIKit + +public final class T3BackgroundGraceSubscriber: ExpoAppDelegateSubscriber { + private var task = UIBackgroundTaskIdentifier.invalid + + public func applicationWillResignActive(_ application: UIApplication) { + guard task == .invalid else { return } + task = application.beginBackgroundTask(withName: "T3 connection grace") { [weak self] in + self?.endTask(application) + } + } + + public func applicationDidBecomeActive(_ application: UIApplication) { + endTask(application) + } + + private func endTask(_ application: UIApplication) { + guard task != .invalid else { return } + application.endBackgroundTask(task) + task = .invalid + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 8cb7ad17e0d0b..801f1c1ee64e6 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -483,6 +483,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont ) postInvalidateOnAnimation() } else { + verticalScroller.forceFinished(true) canvasView.setVerticalOffset(target) } } diff --git a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt index 10d9a45107c73..4a39467cbdc26 100644 --- a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt +++ b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt @@ -22,6 +22,30 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [35], manifest = Config.NONE) class ReviewDiffViewportTest { + @Test + fun immediateNavigationStopsAnActiveFling() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val view = T3ReviewDiffView(activity, AppContext()) + val scroller = view.javaClass.getDeclaredField("verticalScroller").apply { + isAccessible = true + }.get(view) as android.widget.OverScroller + try { + scroller.fling(0, 100, 0, 2000, 0, 0, 0, 10000) + assertTrue(!scroller.isFinished) + view.scrollToTop(false) + view.computeScroll() + val canvas = view.getChildAt(0) + val offset = canvas.javaClass.getDeclaredMethod("verticalOffset").apply { + isAccessible = true + }.invoke(canvas) + assertEquals(0, offset) + assertTrue(scroller.isFinished) + } finally { + view.cleanup() + activity.finish() + } + } + @Test fun resetDiscardsOldRowsAndPendingDecodeBeforePublishingTheNewDestination() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() diff --git a/apps/mobile/modules/t3-source-text/src/util.test.ts b/apps/mobile/modules/t3-source-text/src/util.test.ts new file mode 100644 index 0000000000000..c3af9a1f8ff85 --- /dev/null +++ b/apps/mobile/modules/t3-source-text/src/util.test.ts @@ -0,0 +1,15 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { flattenStyles } from "./util"; + +vi.mock("react-native", () => ({ + I18nManager: { isRTL: false }, + StyleSheet: { + flatten: (styles: ReadonlyArray) => Object.assign({}, ...styles), + }, +})); + +it("encodes numeric bold weights as bold source-text runs", () => { + expect(flattenStyles({ fontWeight: 700 }, undefined).fontWeight).toBe("bold"); + expect(flattenStyles({ fontWeight: "400" }, { fontWeight: "700" }).fontWeight).toBe("bold"); +}); diff --git a/apps/mobile/modules/t3-source-text/src/util.ts b/apps/mobile/modules/t3-source-text/src/util.ts index 461fc7684b4b6..47f219bd3aad6 100644 --- a/apps/mobile/modules/t3-source-text/src/util.ts +++ b/apps/mobile/modules/t3-source-text/src/util.ts @@ -64,7 +64,7 @@ function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFont return "semibold"; case 700: case "700": - return "semibold"; + return "bold"; case 800: case "800": return "bold"; diff --git a/apps/mobile/modules/t3-terminal/android/build.gradle b/apps/mobile/modules/t3-terminal/android/build.gradle index ba79f390bd655..b7d24702b0a38 100644 --- a/apps/mobile/modules/t3-terminal/android/build.gradle +++ b/apps/mobile/modules/t3-terminal/android/build.gradle @@ -9,6 +9,10 @@ android { ndkVersion rootProject.ext.ndkVersion compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -37,4 +41,12 @@ android { dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 1deb3a6170350..5646ab8c5884b 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -271,21 +271,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS inputView.setPadding(0, 0, 0, 0) - inputView.setOnEditorActionListener { _, actionId, event -> - if (readOnly) return@setOnEditorActionListener true - val isKeyUp = event?.action == KeyEvent.ACTION_UP - val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp - val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER && - event.action == KeyEvent.ACTION_DOWN - val isEnter = isImeSend || isHardwareEnter - if (isEnter) { + inputView.setOnEditorActionListener( + TerminalEnterListener({ readOnly }) { // Enter must send CR: raw-mode TUIs treat LF as Ctrl+J (insert newline). onInput(mapOf("data" to "\r")) - true - } else { - false - } - } + }, + ) inputView.setOnKeyListener { _, keyCode, event -> if (readOnly) return@setOnKeyListener true if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalEnterListener.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalEnterListener.kt new file mode 100644 index 0000000000000..6eec4ba97ece2 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalEnterListener.kt @@ -0,0 +1,22 @@ +package expo.modules.t3terminal + +import android.view.KeyEvent +import android.view.inputmethod.EditorInfo +import android.widget.TextView + +internal class TerminalEnterListener( + private val isReadOnly: () -> Boolean, + private val sendEnter: () -> Unit +) : TextView.OnEditorActionListener { + override fun onEditorAction(view: TextView?, actionId: Int, event: KeyEvent?): Boolean { + if (isReadOnly()) return true + val isKeyUp = event?.action == KeyEvent.ACTION_UP + val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp + val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER || + event?.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER + if (isImeSend || (isHardwareEnter && !isKeyUp)) sendEnter() + // An unconsumed Enter key-up makes a single-line TextView move focus to + // the next view, so the terminal stops receiving keys. + return isImeSend || isHardwareEnter + } +} diff --git a/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalEnterListenerTest.kt b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalEnterListenerTest.kt new file mode 100644 index 0000000000000..de04c7adfe4c0 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalEnterListenerTest.kt @@ -0,0 +1,51 @@ +package expo.modules.t3terminal + +import android.app.Activity +import android.view.KeyEvent +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class TerminalEnterListenerTest { + @Test + fun hardwareEnterKeepsFocusOnTheTerminalInput() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + var sent = 0 + val input = EditText(activity).apply { + setSingleLine(true) + isFocusableInTouchMode = true + setOnEditorActionListener(TerminalEnterListener({ false }) { sent += 1 }) + } + val nextControl = Button(activity).apply { isFocusableInTouchMode = true } + val root = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + addView(input) + addView(nextControl) + } + activity.setContentView(root) + // Focus search below the input only finds controls that have bounds. + root.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, 1080, 1920) + assertTrue(input.requestFocus()) + + for (keyCode in listOf(KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER)) { + input.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + input.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) + assertTrue("focus left the input after key $keyCode", input.isFocused) + } + assertEquals(2, sent) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f5c1c6521812e..a49c3856864f1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -176,7 +176,8 @@ "@react-native-menu/menu", "expo-audio", "expo-video", - "expo-network" + "expo-network", + "expo-sharing" ] } }, diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7a0042903a3c3..872ca142c8aaa 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1006,6 +1006,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, + sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, }, }), NotFound: createNativeStackScreen({ diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index 37a7d0e236f19..ea901f2441331 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -1,6 +1,7 @@ import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useIsFocused } from "@react-navigation/native"; +import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { AppActivityIndicator } from "./ui/AppActivityIndicator"; @@ -8,6 +9,8 @@ import { AppText } from "./ui/AppText"; import { SymbolView } from "./ui/AppSymbol"; import { VideoThumbnailImage } from "./VideoThumbnailImage"; import { useMediaActions } from "../state/mediaActions"; +import { fitVideoFrame } from "../lib/videoFrameFit"; +import { loadVideoThumbnail } from "../lib/videoThumbnails"; import { type MediaActionsSource } from "../lib/mediaActionsSource"; import { useMediaVideoPlayback } from "../lib/useMediaVideoPlayback"; import { MediaActionsMenu } from "./MediaActionsMenu"; @@ -136,6 +139,8 @@ interface MediaVideoPlayerProps { readonly visible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; + /** Fill the parent and size the frame to the video's shape instead of a fixed 16:9 slot. */ + readonly fitToVideo?: boolean; readonly autoPlay?: boolean; readonly paused?: boolean; readonly onPlaybackStart?: () => void; @@ -145,16 +150,41 @@ interface MediaVideoPlayerProps { function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { const mediaActions = useMediaActions(props.actionsSource); + const isFocused = useIsFocused(); const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); + const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null); + const sizeSource = props.fitToVideo && isFocused && videoSize === null ? props.uri : null; + // Queue before the poster so early Play cannot cancel the extraction that supplies frame dimensions. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads native thumbnail dimensions independently of the poster's lifetime + useLayoutEffect(() => { + if (sizeSource === null) return; + // Thumbnails are oriented like the displayed frame, unlike VideoTrack.size. + const controller = new AbortController(); + void loadVideoThumbnail( + props.thumbnailKey, + async () => ({ uri: sizeSource, dispose: () => undefined }), + controller.signal, + ).then((thumbnail) => { + if (thumbnail && !controller.signal.aborted) { + setVideoSize({ width: thumbnail.width, height: thumbnail.height }); + } + }); + return () => controller.abort(); + }, [props.thumbnailKey, sizeSource]); + const fittedFrame = + props.fitToVideo && videoSize && bounds ? fitVideoFrame(videoSize, bounds, 480) : null; - return ( + const frame = ( @@ -214,6 +244,18 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { )} ); + if (!props.fitToVideo) return frame; + return ( + { + const { width, height } = event.nativeEvent.layout; + setBounds({ width, height }); + }} + > + {frame} + + ); } export function MediaVideoPlayer(props: MediaVideoPlayerProps) { diff --git a/apps/mobile/src/components/RelativeTime.tsx b/apps/mobile/src/components/RelativeTime.tsx new file mode 100644 index 0000000000000..8d2067a4907f9 --- /dev/null +++ b/apps/mobile/src/components/RelativeTime.tsx @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from "react"; +import { msUntilRelativeTimeChange, relativeTime } from "../lib/time"; + +// The React Compiler caches inline relativeTime calls on the timestamp alone. +export function RelativeTime(props: { timestamp: string }) { + const subscribe = (onChange: () => void) => { + let timer: ReturnType | undefined; + const schedule = () => { + const delayMs = msUntilRelativeTimeChange(props.timestamp); + if (delayMs === null) return; + timer = setTimeout(() => { + onChange(); + schedule(); + }, delayMs); + }; + schedule(); + return () => clearTimeout(timer); + }; + const getSnapshot = () => relativeTime(props.timestamp); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/mobile/src/components/ScrollComposeButton.android.tsx b/apps/mobile/src/components/ScrollComposeButton.android.tsx index e9e57373e19c0..d107d9be5d8a0 100644 --- a/apps/mobile/src/components/ScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/ScrollComposeButton.android.tsx @@ -3,13 +3,12 @@ import { Box, ExtendedFloatingActionButton, Icon, Text } from "@expo/ui/jetpack- import { defaultMinSize, fillMaxWidth, - graphicsLayer, height, onSizeChanged, size, } from "@expo/ui/jetpack-compose/modifiers"; import { useState } from "react"; -import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; +import { I18nManager, Pressable, View, type StyleProp, type ViewStyle } from "react-native"; import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { resolveScaledTextRole } from "../lib/appearancePreferences"; @@ -37,7 +36,14 @@ export function ScrollComposeButton(props: { className={props.className} style={[props.style, { left: 20, height: fabSize }]} > - + diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts index 92512bd2c3e87..47aed622569f6 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -105,6 +105,56 @@ describe("extractAgentNotificationDeepLink", () => { }); describe("routeAgentNotificationResponseOnce", () => { + it("retries a failed initial response and deduplicates it after navigation succeeds", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({ deepLink: "/threads/env/thread" }) as NotificationResponse; + const handledResponseIds = new Set(); + const navigate = vi.fn().mockImplementationOnce(() => { + throw new Error("navigation not ready"); + }); + const clearLastResponse = vi.fn(() => Promise.resolve()); + const consume = () => + consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse, + handleResponse: (response) => + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }), + }); + + await consume(); + expect(handledResponseIds.size).toBe(0); + expect(clearLastResponse).not.toHaveBeenCalled(); + + await consume(); + expect(navigate).toHaveBeenCalledTimes(2); + expect(navigate).toHaveBeenLastCalledWith("/threads/env/thread"); + expect(clearLastResponse).toHaveBeenCalledOnce(); + + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }); + expect(navigate).toHaveBeenCalledTimes(2); + }); + + it("discards malformed destination ids without leaving the response pending", async () => { + const response = responseWithData({ + environmentId: "env", + threadId: "\ud800", + }) as NotificationResponse; + const handledResponseIds = new Set(); + const navigate = vi.fn(); + const clearLastResponse = vi.fn(() => Promise.resolve()); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse, + handleResponse: (response) => + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }), + }); + + expect(navigate).not.toHaveBeenCalled(); + expect(clearLastResponse).toHaveBeenCalledOnce(); + expect(handledResponseIds.has("notification-1")).toBe(true); + }); + it("does not navigate twice when the initial and listener responses refer to one notification", () => { const handledResponseIds = new Set(); const navigations: Array = []; diff --git a/apps/mobile/src/features/agent-awareness/notificationPayload.ts b/apps/mobile/src/features/agent-awareness/notificationPayload.ts index a9dfe80369374..a86d1c5c14ba4 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPayload.ts @@ -41,7 +41,11 @@ function encodeThreadDeepLink(input: { if (input.environmentId.length === 0 || input.threadId.length === 0) { return null; } - return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; + try { + return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; + } catch { + return null; + } } function normalizeThreadDeepLink(value: string): string | null { @@ -104,11 +108,11 @@ export function routeAgentNotificationResponseOnce(input: { if (responseId && input.handledResponseIds.has(responseId)) { return; } - if (responseId) { - input.handledResponseIds.add(responseId); - } const deepLink = extractAgentNotificationDeepLink(input.response); if (deepLink) { input.navigate(deepLink); } + if (responseId) { + input.handledResponseIds.add(responseId); + } } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 62e43c04cda61..e958dbfdaaa18 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -24,7 +24,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { relativeTime } from "../../lib/time"; +import { RelativeTime } from "../../components/RelativeTime"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useServerConfigs } from "../../state/entities"; import { useEnvironmentScope } from "../../state/session"; @@ -193,7 +193,6 @@ function ArchivedThreadRow(props: { props.thread.environmentId, AuthOrchestrationOperateScope, ); - const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); @@ -219,7 +218,7 @@ function ArchivedThreadRow(props: { {props.thread.title} - {timestamp} + {subtitle.length > 0 ? ( diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx index 842d58c9aa371..03b6daaa4e715 100644 --- a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -14,6 +14,7 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import * as Dialog from "../../components/ui/Dialog"; import { Menu } from "../../components/ui/Menu"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; import { deregisterManagedRelayEnvironmentCommand, @@ -46,6 +47,9 @@ export function T3ConnectProfilePage() { const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { reportFailure: false, }); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refreshAfterMutation, { + reportFailure: false, + }); const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = useState(null); const mutationPendingRef = useRef(false); @@ -75,6 +79,7 @@ export function T3ConnectProfilePage() { return { accountId, linkedAtById }; }); environmentsState.refresh(); + void refreshRelayEnvironments(); return; } if (isAtomCommandInterrupted(result)) return; diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 1133e17113ee4..c7866cea6ce06 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -283,6 +283,9 @@ export function ConnectionEnvironmentRow(props: { )} props.onReconnect(props.environment.environmentId)} @@ -296,6 +299,8 @@ export function ConnectionEnvironmentRow(props: { props.onRemove(props.environment.environmentId)} > diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 8e7ee048587f0..117dbc44ede58 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -361,6 +361,7 @@ function useThreadFilesWorkspace(params: { readonly threadId?: string | string[]; readonly cwd?: string | string[]; readonly projectName?: string | string[]; + readonly path?: string | string[]; }) { const routeEnvironmentId = firstRouteParam(params.environmentId); const routeThreadId = firstRouteParam(params.threadId); @@ -382,6 +383,7 @@ function useThreadFilesWorkspace(params: { cwd: routeCwd ?? selectedThreadCwd ?? project?.workspaceRoot ?? null, environmentId, projectName: routeProjectName ?? project?.title ?? "Files", + relativePath: normalizeRoutePath(params.path), selectedThread, threadId, }; @@ -592,11 +594,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const params = props.route.params; - const relativePath = normalizeRoutePath(params.path); const targetLine = normalizeRouteLine(firstRouteParam(params.line)); - const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( - props.route.params, - ); + const { cwd, environmentId, projectName, relativePath, selectedThread, threadId } = + useThreadFilesWorkspace(props.route.params); const fileAccessSession = useEnvironmentQuery( environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, ); diff --git a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx index 72b31f061e964..7ee4a818954d5 100644 --- a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx @@ -19,6 +19,7 @@ export function WorkspaceFileVideoPreview(props: { resolvePlaybackUri={props.resolvePlaybackUri} name={props.name} thumbnailKey={props.thumbnailKey} + fitToVideo actionsSource={props.source?.actionsSource} /> diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index fe1401326ab56..c522da701e75c 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -10,7 +10,10 @@ import { useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; +import { + pickableProjectGroups, + resolveScratchProjectScopeKey, +} from "@t3tools/client-runtime/state/project-grouping"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects } from "../../state/entities"; @@ -138,11 +141,13 @@ export function HomeRouteScreen() { ); const projectFilterOptions = useMemo( () => - buildHomeProjectScopes({ - projects, - environmentId: selectedEnvironmentId, - projectGroupingMode: listOptions.projectGroupingMode, - }).map((scope) => ({ + pickableProjectGroups( + buildHomeProjectScopes({ + projects, + environmentId: selectedEnvironmentId, + projectGroupingMode: listOptions.projectGroupingMode, + }), + ).map((scope) => ({ key: scope.key, label: scope.title, })), diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 1edb92f0d556b..243e693d1b893 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -9,6 +9,7 @@ import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -53,7 +54,7 @@ import { useThreadListV2 } from "../threads/useThreadListV2"; import { useThreadListNow } from "../threads/use-thread-list-now"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; -import { threadListEnvironmentsAtom } from "../../state/server"; +import { threadListEnvironmentsAtom } from "../../state/presentation"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { @@ -376,7 +377,11 @@ export function HomeScreen(props: HomeScreenProps) { })(); const v2ProjectScopeKey = props.selectedProjectKey; - const v2ScopedProjectGroup = findHomeProjectScope(projectScopes, v2ProjectScopeKey); + // An archived project's filter reads as all projects, matching the filter menu. + const v2ScopedProjectGroup = findHomeProjectScope( + pickableProjectGroups(projectScopes), + v2ProjectScopeKey, + ); const threadSearch = useThreadSearch( searchEnvironmentIds, props.searchQuery, @@ -711,7 +716,10 @@ export function HomeScreen(props: HomeScreenProps) { ({ key: scope.key, label: scope.title }))} + projects={pickableProjectGroups(projectScopes).map((scope) => ({ + key: scope.key, + label: scope.title, + }))} selectedEnvironmentId={props.selectedEnvironmentId} selectedProjectKey={props.selectedProjectKey} onEnvironmentChange={props.onEnvironmentChange} diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 82eab6b384b9c..1b2e1a8c541f7 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -27,6 +27,8 @@ export interface HomeProjectScope { readonly representative: EnvironmentProject; readonly projects: ReadonlyArray; readonly projectRefs: ReadonlyArray; + readonly pinned: boolean; + readonly archived: boolean; } function getProjectSortTimestamp( @@ -62,6 +64,8 @@ export function buildHomeProjectScopes(input: { representative: group.representative, projects: group.members.map((member) => member.project), projectRefs: group.memberProjectRefs, + pinned: group.pinned, + archived: group.archived, }; }); } diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index ec0f6a14558dd..9313aef299881 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -13,6 +13,7 @@ import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ + connectedEnvironmentIds: [] as EnvironmentId[], pendingOrder: null as object | null, dropBusy: false, failedAction: null as string | null, @@ -60,6 +61,9 @@ vi.mock("../../state/session", () => ({ readEnvironmentScope: (environmentId: string, scope: string) => state.scopes.get(environmentId)?.has(scope) === true, })); +vi.mock("../../state/presentation", () => ({ + connectedEnvironmentIdsAtom: "connected-environments", +})); vi.mock("../../state/server", () => ({ environmentServerConfigsAtom: "server-configs", })); @@ -69,28 +73,30 @@ vi.mock("../../state/atom-registry", () => ({ state.dropBusy = value; }, get: (atom: string) => - atom === "thread-drop-busy" - ? state.dropBusy - : atom === "thread-shells" - ? state.shells - : atom === "queued-thread-keys" - ? new Set() - : new Map( - [...state.scopes.keys()].map((environmentId) => [ - environmentId, - { - environment: { - capabilities: { - threadSettlement: true, - threadSnooze: true, - threadPinning: true, - threadPinReorder: true, - threadTitleRegeneration: true, + atom === "connected-environments" + ? state.connectedEnvironmentIds + : atom === "thread-drop-busy" + ? state.dropBusy + : atom === "thread-shells" + ? state.shells + : atom === "queued-thread-keys" + ? new Set() + : new Map( + [...state.scopes.keys()].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { + threadSettlement: true, + threadSnooze: true, + threadPinning: true, + threadPinReorder: true, + threadTitleRegeneration: true, + }, }, }, - }, - ]), - ), + ]), + ), }, })); vi.mock("../../state/use-atom-command", () => ({ @@ -130,6 +136,7 @@ vi.mock("../../state/threads", () => ({ "pin", "unpin", "reorderPin", + "reorderActive", "updateMetadata", ].map((action) => [ action, @@ -179,6 +186,7 @@ const mutationCases = [ ] as const; beforeEach(() => { + state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; state.failedAction = null; state.archivedRefreshes = []; state.pendingOrder = null; @@ -286,6 +294,76 @@ describe("pinned thread operation permissions", () => { }); }); +describe("thread reorder connection availability", () => { + it.each(["active", "pinned"] as const)( + "rejects %s materialization involving a cached capable offline neighbor", + async (section) => { + const moved = makeThread({ + pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null, + }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("offline-neighbor"), + environmentId: otherEnvironmentId, + createdAt: "2026-09-02T00:00:00.000Z", + pinnedAt: moved.pinnedAt, + }), + ]; + state.scopes.get(otherEnvironmentId)?.add(AuthOrchestrationOperateScope); + state.connectedEnvironmentIds = [primaryEnvironmentId]; + + const { moveThread } = useThreadListActions(); + expect(await moveThread(moved, "up")).toBe(false); + expect(state.requests).toEqual([]); + + state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId]; + + expect(await moveThread(moved, "up")).toBe(true); + expect(state.requests).toHaveLength(2); + expect(new Set(state.requests.map(({ environmentId }) => environmentId))).toEqual( + new Set([primaryEnvironmentId, otherEnvironmentId]), + ); + }, + ); + + it.each(["active", "pinned"] as const)( + "uses the latest connection state in a retained %s move callback", + async (section) => { + const moved = makeThread({ + pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null, + pinOrderKey: section === "pinned" ? "m" : null, + activeOrderKey: section === "active" ? "m" : null, + }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("offline-neighbor"), + environmentId: otherEnvironmentId, + pinnedAt: moved.pinnedAt, + pinOrderKey: section === "pinned" ? "g" : null, + activeOrderKey: section === "active" ? "g" : null, + }), + ]; + const { moveThread } = useThreadListActions(); + state.connectedEnvironmentIds = []; + + expect(await moveThread(moved, "up")).toBe(false); + expect(state.requests).toEqual([]); + + state.connectedEnvironmentIds = [primaryEnvironmentId]; + + expect(await moveThread(moved, "up")).toBe(true); + expect(state.requests).toEqual([ + expect.objectContaining({ + action: section === "pinned" ? "reorderPin" : "reorderActive", + environmentId: primaryEnvironmentId, + }), + ]); + }, + ); +}); + describe("thread action undo", () => { it.each([ ["settleThread", "settle", "Settled", "unsettle"], diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index cac37f0552802..248c436905311 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -15,6 +15,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; +import { connectedEnvironmentIdsAtom } from "../../state/presentation"; import { readEnvironmentScope } from "../../state/session"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; @@ -591,6 +592,10 @@ export function useThreadListActions(): { return settled; } const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const connectedEnvironmentIds = new Set(appAtomRegistry.get(connectedEnvironmentIdsAtom)); + + if (!connectedEnvironmentIds.has(thread.environmentId)) return false; + if (!configs.has(thread.environmentId)) { Dialog.alert( "Could not move thread", @@ -610,7 +615,9 @@ export function useThreadListActions(): { allThreads: shells, ordered, section, - reorderableEnvironmentIds: new Set(configs.keys()), + reorderableEnvironmentIds: new Set( + [...configs.keys()].filter((environmentId) => connectedEnvironmentIds.has(environmentId)), + ), })(scopedThreadKey(thread.environmentId, thread.id), direction); if (assignments === null) return false; const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 88987819ddf00..841eaae7c6dc2 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -8,7 +8,7 @@ import { type EnvironmentThreadSearchMatch, type ShownOrder, } from "@t3tools/client-runtime/state/thread-search"; -import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import { isListedThreadLineage, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { foldSearchQuery, PALETTE_MESSAGE_LIMIT, @@ -401,7 +401,11 @@ export function CommandPalette(props: { })), ); } - const projectItems: CommandPaletteItem[] = projects.map((project) => ({ + const openProjects = projects.filter((project) => project.archivedAt == null); + const projectItems: CommandPaletteItem[] = [ + ...openProjects.filter((project) => project.pinnedAt != null), + ...openProjects.filter((project) => project.pinnedAt == null), + ].map((project) => ({ key: `project:${scopedProjectKey(project.environmentId, project.id)}`, kind: "project", project, @@ -422,10 +426,7 @@ export function CommandPalette(props: { }), })); const threadItems: CommandPaletteItem[] = threads - .filter( - (thread) => - thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent", - ) + .filter((thread) => thread.archivedAt === null && isListedThreadLineage(thread.lineage)) .map((thread) => { const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); const environment = diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c3b46cf8e24c2..517d3ddf2981b 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -15,7 +15,16 @@ import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3too import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; -import { use, useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + use, + useDeferredValue, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { @@ -129,12 +138,48 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi ); })(); - // useFocusEffect re-runs when the renderer changes while focused, so the - // pane always shows this render's content, and its cleanup deactivates on - // blur and unmount. - useFocusEffect(() => - wrappedRender === undefined ? undefined : registerWorkspaceInspector(wrappedRender), - ); + const focusedRef = useRef(false); + const deactivateRef = useRef<(() => void) | null>(null); + + const syncRegistration = useEffectEvent(() => { + if (!focusedRef.current || wrappedRender === undefined) { + deactivateRef.current?.(); + return; + } + deactivateRef.current = registerWorkspaceInspector(wrappedRender); + }); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Navigation focus events own the inspector registration lifetime. + useEffect(() => { + if (navigation === undefined) return; + const focus = () => { + if (focusedRef.current) return; + focusedRef.current = true; + syncRegistration(); + }; + const blur = () => { + focusedRef.current = false; + syncRegistration(); + }; + if (navigation.isFocused()) focus(); + const unsubscribeFocus = navigation.addListener("focus", focus); + const unsubscribeBlur = navigation.addListener("blur", blur); + return () => { + unsubscribeFocus(); + unsubscribeBlur(); + focusedRef.current = false; + deactivateRef.current?.(); + deactivateRef.current = null; + }; + }, [navigation]); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Changed pane content replaces the focused inspector registration. + useEffect(() => { + if (focusedRef.current) { + syncRegistration(); + } + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed pane content replaces its existing native workspace registration. + }, [wrappedRender]); } export function AdaptiveWorkspaceLayout(props: { diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 218d01041561d..98f6a909899a9 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -713,6 +713,9 @@ function useCreateProject(environment: EnvironmentOption | null) { const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const updateProject = useAtomCommand(projectEnvironment.update, { + label: "project unarchive", + }); const projects = useProjects(); return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { @@ -734,7 +737,19 @@ function useCreateProject(environment: EnvironmentOption | null) { path: workspaceRoot, }); if (existing) { - Dialog.alert("Project already exists", existing.title); + // Adding an archived project's folder again brings the project back. + const unarchived = + existing.archivedAt == null || + ( + await updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }) + )._tag === "Success"; + Dialog.alert( + "Project already exists", + unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, + ); openNewTaskDraft( { environmentId: existing.environmentId, diff --git a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx index f8f7a5f9c5b15..6bdb399d0ad30 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx @@ -9,7 +9,7 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { Menu } from "../../components/ui/Menu"; import type { MenuProps } from "../../components/ui/Menu.types"; import { cn } from "../../lib/cn"; -import { relativeTime } from "../../lib/time"; +import { RelativeTime } from "../../components/RelativeTime"; import { selectedThreadRowColors, THREAD_LIST_V2_SIDEBAR_ROW_STYLE, @@ -132,7 +132,7 @@ export const PullRequestListRow = memo( ) : null} - {relativeTime(item.updatedAt)} + ReadonlyArray) | null>( null, ); +const LineBreaksContext = createContext(false); export function PullRequestMarkdownRepository(props: { /** The change request the markdown belongs to, whose host decides what its text links. */ @@ -44,6 +48,7 @@ export function PullRequestMarkdownRepository(props: { provider === undefined || url === undefined ? null : changeRequestReferenceAutolinker({ provider, url }); + const lineBreaks = sourceControlClients.find(provider ?? "")?.hardLineBreaks ?? false; const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, @@ -53,7 +58,9 @@ export function PullRequestMarkdownRepository(props: { const media = cwd ? { environmentId: target.environmentId, cwd } : null; return ( - {props.children} + + {props.children} + ); } @@ -88,6 +95,7 @@ function MarkdownImage(props: { export function PullRequestMarkdown(props: { readonly markdown: string; readonly imageGroupMarkdown?: string; + readonly lineBreaks?: boolean; }) { const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]); return ( @@ -98,6 +106,7 @@ export function PullRequestMarkdown(props: { key={segment.id} markdown={segment.text} imageGroupMarkdown={props.imageGroupMarkdown ?? props.markdown} + lineBreaks={props.lineBreaks} /> ) : ( @@ -110,7 +119,9 @@ export function PullRequestMarkdown(props: { function PullRequestMarkdownContent(props: { readonly markdown: string; readonly imageGroupMarkdown?: string; + readonly lineBreaks: boolean | undefined; }) { + const hostLineBreaks = useContext(LineBreaksContext); const autolinks = useContext(AutolinksContext); const mediaContext = useContext(PullRequestMediaContext); const markdown = linkReferenceAutolinks(props.markdown, autolinks); @@ -136,6 +147,7 @@ function PullRequestMarkdownContent(props: { - Updated {relativeTime(pr.updatedAt)} ago + Updated ago @@ -873,19 +873,25 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps - + {/* UIKit draws the tapped control's glass lens outside the hidden + tab's opacity, so only the visible tab mounts the control. */} + {Platform.OS !== "ios" || section === tab ? ( + + ) : ( + + )} ) : null} diff --git a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx index b2f51a9bd0abb..cbb751c74c4c7 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx @@ -4,6 +4,7 @@ import { Platform, View } from "react-native"; import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import { AppText as Text } from "../../components/ui/AppText"; import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; +import { pullRequestLayerParams } from "./pullRequestStack"; import { pullRequestStatus } from "./pullRequestStatus"; import type { PullRequestParams } from "./usePullRequestDiff"; import type { PullRequestStackState } from "./usePullRequestStack"; @@ -66,7 +67,7 @@ export function PullRequestStackCard(props: { selected ? undefined : () => { - const params = { ...props.params, number: layer.number }; + const params = pullRequestLayerParams(props.params, layer.number); if (Platform.OS === "android") selectDetail({ name: "PullRequest", params }); else navigation.dispatch(StackActions.push("PullRequest", params)); diff --git a/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx b/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx index 6b4eef34abd60..8a35fb7b2ee45 100644 --- a/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx +++ b/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx @@ -39,18 +39,15 @@ export function GuideNotice({ state }: { readonly state: ReturnType {notice.description} - + {state.canWrite || busy || notice.kind === "unavailable" ? ( - - + + ); +} + +it.each([ + { width: 1920, height: 1080, axis: "width", typed: "2560", expected: "2560 × 1440" }, + { width: 390, height: 844, axis: "height", typed: "2532", expected: "1170 × 2532" }, +])("keeps each typed $axis digit until the locked size is committed", async (testCase) => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: `Viewport ${testCase.axis}` }); + await userEvent.clear(input); + await userEvent.type(input, testCase.typed); + await expect.element(input).toHaveValue(Number(testCase.typed)); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent(testCase.expected); +}); + +it("clamps a completed width below the locked minimum when leaving the toolbar", async () => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: "Viewport width" }); + await userEvent.clear(input); + await userEvent.type(input, "256"); + await expect.element(input).toHaveValue(256); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent("427 × 240"); +}); + +it("still limits a completed locked size to the viewport area cap", async () => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: "Viewport width" }); + await userEvent.clear(input); + await userEvent.type(input, "3840"); + await expect.element(input).toHaveValue(3840); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent("2880 × 2880"); +}); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index dd5b707cfebdb..977431aa6ff4d 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -51,6 +51,7 @@ export function BrowserDeviceToolbar({ const [customSize, setCustomSize] = useState<{ readonly width: string; readonly height: string; + readonly axis?: "width" | "height"; } | null>(null); const presentedSize = customSize ?? { width: String(setting.width), @@ -61,15 +62,25 @@ export function BrowserDeviceToolbar({ PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === setting.presetId) ? setting.presetId : RESPONSIVE_VALUE; - const customWidth = Number(presentedSize.width); - const customHeight = Number(presentedSize.height); + const typedWidth = Number(presentedSize.width); + const typedHeight = Number(presentedSize.height); + const { width: customWidth, height: customHeight } = + aspectRatio === null || customSize?.axis === undefined + ? { width: typedWidth, height: typedHeight } + : resizeFreeformViewport( + { width: typedWidth, height: typedHeight }, + { x: 0, y: 0 }, + 1, + customSize?.axis === "height" ? "south" : "east", + aspectRatio, + ); const customValid = - Number.isInteger(customWidth) && - Number.isInteger(customHeight) && - customWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && - customWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && - customHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && - customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && + Number.isInteger(typedWidth) && + Number.isInteger(typedHeight) && + typedWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && + typedWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && + typedHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && + typedHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA; const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => { @@ -96,6 +107,7 @@ export function BrowserDeviceToolbar({ const next = { width: axis === "width" ? value : (current?.width ?? String(setting.width)), height: axis === "height" ? value : (current?.height ?? String(setting.height)), + axis, }; const numeric = Number(value); if ( @@ -115,7 +127,11 @@ export function BrowserDeviceToolbar({ axis === "width" ? "east" : "south", aspectRatio, ); - return { width: String(resized.width), height: String(resized.height) }; + return { + ...next, + width: axis === "width" ? value : String(resized.width), + height: axis === "height" ? value : String(resized.height), + }; }); }; diff --git a/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx new file mode 100644 index 0000000000000..47cfb623dc7f9 --- /dev/null +++ b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx @@ -0,0 +1,222 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { + PreviewStreamControl, + PreviewStreamEvents, + PreviewStreamInput, +} from "@t3tools/client-runtime/preview/server-browser-stream"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { act, render } from "../../test/render"; + +const mocks = vi.hoisted(() => ({ + createClient: vi.fn<(target: unknown, handlers: PreviewStreamEvents) => unknown>(), + sent: [] as PreviewStreamInput[], + access: { url: "ws://preview.test", token: "test" }, + painted: null as (() => void) | null, +})); + +vi.mock(import("@t3tools/client-runtime/preview/server-browser-stream"), async (original) => ({ + ...(await original()), + createPreviewStreamClient: mocks.createClient as never, + createPreviewFramePainter: (_canvas, onPainted) => { + mocks.painted = onPainted ?? null; + return { paint: () => undefined, stop: () => undefined }; + }, +})); + +vi.mock(import("~/state/previewStream"), async (original) => ({ + ...(await original()), + usePreviewStreamAccess: () => mocks.access as never, +})); + +import { ServerBrowserSurface } from "./ServerBrowserSurface"; + +function setup() { + mocks.sent.length = 0; + mocks.painted = null; + mocks.createClient.mockReset().mockImplementation(() => ({ + send: (input: PreviewStreamInput) => mocks.sent.push(input), + stop: () => undefined, + })); +} + +async function renderSurface(controller: PreviewStreamControl["controller"]) { + const container = document.createElement("div"); + container.style.cssText = "width: 400px; height: 300px; display: flex;"; + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( + , + { container }, + ), + ); + await vi.waitFor(() => expect(mocks.createClient).toHaveBeenCalled()); + const handlers = mocks.createClient.mock.calls.at(-1)![1]; + await act(() => + handlers.onControl?.({ canOperate: true, controller, generation: 1, dialog: null }), + ); + mocks.sent.length = 0; + const input = container.querySelector("textarea")!; + const canvas = container.querySelector("canvas")!; + stack.move(); + return { screen, input, canvas }; +} + +const keys = () => mocks.sent.filter((input) => input.type === "key"); + +describe("ServerBrowserSurface clipboard commands", () => { + it("sends a native Copy or Cut menu command to the page once", async () => { + setup(); + const { screen, input, canvas } = await renderSurface("you"); + await using _screen = screen; + // Chromium runs a script Copy or Cut only during a user gesture, as a menu click is. + await userEvent.click(canvas); + input.focus(); + mocks.sent.length = 0; + + // Like Electron's webContents.copy(): the browser's Copy command, with no key. + expect(document.execCommand("copy")).toBe(true); + expect(keys()).toEqual([ + { type: "key", action: "down", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + { type: "key", action: "up", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + ]); + + mocks.sent.length = 0; + expect(document.execCommand("cut")).toBe(true); + expect(input.value).toBe("\u200b"); + expect(keys()).toEqual([ + { type: "key", action: "down", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 }, + { type: "key", action: "up", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 }, + ]); + }); + + it("sends a keyboard Copy or Cut once and leaves menu Copy working after any shortcut", async () => { + setup(); + const { screen, input, canvas } = await renderSurface("you"); + await using _screen = screen; + await userEvent.click(canvas); + input.focus(); + const prevented: boolean[] = []; + const onKeyDown = (event: KeyboardEvent) => { + if (/^[cx]$/i.test(event.key)) prevented.push(event.defaultPrevented); + }; + window.addEventListener("keydown", onKeyDown); + using _listener = { [Symbol.dispose]: () => window.removeEventListener("keydown", onKeyDown) }; + + // An unprevented copy or cut key also makes the browser fire copy or cut: here Chromium's + // Ctrl key binding, on macOS Electron's menu after the Meta keyup. + for (const [shortcut, cancels] of [ + ["{Control>}x{/Control}", true], + ["{Control>}c{/Control}", true], + ["{Meta>}{x>}{/Meta}{/x}", true], + ["{Control>}{Shift>}c{/Shift}{/Control}", false], + ["{Control>}{Alt>}x{/Alt}{/Control}", false], + ] as const) { + mocks.sent.length = 0; + prevented.length = 0; + await userEvent.keyboard(shortcut); + expect(prevented, shortcut).toEqual([cancels]); + expect(keys().filter((key) => /^[cx]$/i.test(key.key)).length, shortcut).toBe(2); + expect(input.value).toBe("\u200b"); + + mocks.sent.length = 0; + expect(document.execCommand("copy")).toBe(true); + expect(keys(), shortcut).toEqual([ + { type: "key", action: "down", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + { type: "key", action: "up", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + ]); + } + }); + + it("sends nothing when another viewer controls the page", async () => { + setup(); + const { screen, input } = await renderSurface("another-viewer"); + await using _screen = screen; + + expect( + input.dispatchEvent(new ClipboardEvent("copy", { bubbles: true, cancelable: true })), + ).toBe(false); + expect( + input.dispatchEvent(new ClipboardEvent("cut", { bubbles: true, cancelable: true })), + ).toBe(false); + expect(mocks.sent).toEqual([]); + }); +}); + +const mouseUp = (canvas: HTMLCanvasElement, button: number) => { + const event = new MouseEvent("mouseup", { button, bubbles: true, cancelable: true }); + canvas.dispatchEvent(event); + return event; +}; + +describe("ServerBrowserSurface history", () => { + it("routes mouse thumb buttons to the preview's history instead of the app", async () => { + setup(); + const { screen, input } = await renderSurface("you"); + await using _screen = screen; + const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!; + + expect(mouseUp(canvas, 3).defaultPrevented).toBe(true); + expect(mouseUp(canvas, 4).defaultPrevented).toBe(true); + expect(mocks.sent).toEqual([ + { type: "history", delta: -1 }, + { type: "history", delta: 1 }, + ]); + }); + + it("leaves other mouse buttons alone", async () => { + setup(); + const { screen, input } = await renderSurface("you"); + await using _screen = screen; + const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!; + + expect(mouseUp(canvas, 0).defaultPrevented).toBe(false); + expect(mocks.sent).toEqual([]); + }); + + it("does not forward thumb-button presses to the page", async () => { + setup(); + const { screen, input } = await renderSurface("you"); + await using _screen = screen; + const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!; + canvas.width = 400; + canvas.height = 300; + // Synthetic pointers have no active pointer id to capture. + canvas.setPointerCapture = () => undefined; + const handlers = mocks.createClient.mock.calls.at(-1)![1]; + await act(() => { + mocks.painted?.(); + handlers.onViewport({ width: 400, height: 300 }); + }); + const { left, top } = canvas.getBoundingClientRect(); + const press = (type: "pointerdown" | "pointerup", button: number) => + canvas.dispatchEvent( + new PointerEvent(type, { + button, + buttons: type === "pointerdown" ? 1 << button : 0, + clientX: left + 10, + clientY: top + 10, + pointerType: "mouse", + bubbles: true, + }), + ); + const mouseInputs = () => mocks.sent.filter((input) => input.type === "mouse"); + + for (const button of [3, 4]) { + press("pointerdown", button); + press("pointerup", button); + } + expect(mouseInputs()).toEqual([]); + + press("pointerdown", 0); + expect(mouseInputs()).not.toEqual([]); + }); +}); diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index c6ad2f51c3f31..e32e8f76f1a9d 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -20,8 +20,10 @@ import type { PreviewViewportSetting, } from "@t3tools/contracts"; import { + type ClipboardEvent, type FormEvent, type KeyboardEvent, + type MouseEvent, type PointerEvent, type Ref, useEffect, @@ -56,6 +58,9 @@ const PROBE_REUSE_MS = 10_000; const INPUT_SENTINEL = "\u200b"; const BACKSPACE = { key: "Backspace", code: "Backspace", keyCode: 8 } as const; const DELETE = { key: "Delete", code: "Delete", keyCode: 46 } as const; +const COPY = { key: "c", code: "KeyC", keyCode: 67 } as const; +const CUT = { key: "x", code: "KeyX", keyCode: 88 } as const; +const CTRL = 2; const MAX_UNAUTHORIZED_REFUSALS = 3; const TAP_SLOP_PX = 8; const MULTI_CLICK_MS = 500; @@ -94,6 +99,10 @@ const buttonOf = (button: number): PreviewStreamMouseButton => const pressedButtonOf = (buttons: number): PreviewStreamMouseButton => buttons & 1 ? "left" : buttons & 2 ? "right" : buttons & 4 ? "middle" : "none"; +// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. +const historyDeltaOf = (button: number): -1 | 1 | null => + button === 3 ? -1 : button === 4 ? 1 : null; + /** Browsers may refuse a write that arrives after the key press; a toast button is a fresh gesture. */ async function copyPageText(text: string) { try { @@ -490,6 +499,8 @@ export function ServerBrowserSurface(props: { const handlePointer = (event: PointerEvent) => { if (controlRef.current?.controller !== "you") return; + // Thumb buttons drive this tab's history in handleMouseUp, never the page. + if (historyDeltaOf(event.button) !== null) return; const { type, pointerId, clientX, clientY } = event; const cancelled = type === "pointercancel"; const touch = touchRef.current; @@ -626,15 +637,36 @@ export function ServerBrowserSurface(props: { } }; + // Chromium runs a thumb button's back/forward on mouseup in this document, which + // would move the app's router. Cancel it and send the press to the preview, as + // the toolbar does. + const handleMouseUp = (event: MouseEvent) => { + const delta = historyDeltaOf(event.button); + if (delta === null) return; + event.preventDefault(); + send({ type: "history", delta }); + }; + const resetInput = (textarea: HTMLTextAreaElement) => { keySentRef.current = false; textarea.value = INPUT_SENTINEL; textarea.setSelectionRange(INPUT_SENTINEL.length, INPUT_SENTINEL.length); }; - const sendKeyPress = (key: typeof BACKSPACE | typeof DELETE) => { - send({ type: "key", action: "down", ...key, modifiers: 0 }); - send({ type: "key", action: "up", ...key, modifiers: 0 }); + const sendKeyPress = ( + key: typeof BACKSPACE | typeof DELETE | typeof COPY | typeof CUT, + modifiers = 0, + ) => { + send({ type: "key", action: "down", ...key, modifiers }); + send({ type: "key", action: "up", ...key, modifiers }); + }; + + // Edit menu Copy and Cut fire no key, so send the shortcut the server copies on. + const handleClipboard = (key: typeof COPY | typeof CUT, event: ClipboardEvent) => { + // Keep the sentinel off the clipboard. + event.preventDefault(); + if (controlRef.current?.controller !== "you") return; + sendKeyPress(key, CTRL); }; const handleKey = (action: "down" | "up", event: KeyboardEvent) => { @@ -679,6 +711,15 @@ export function ServerBrowserSurface(props: { action === "down" && !shortcut && (text !== undefined || event.key === "Backspace" || event.key === "Delete"); + // The key copies or cuts in the page, so stop the browser's own copy or cut repeating it. + if ( + event.ctrlKey !== event.metaKey && + !event.altKey && + !event.shiftKey && + ["c", "x"].includes(event.key.toLowerCase()) + ) { + event.preventDefault(); + } if (shortcut) return; event.preventDefault(); event.stopPropagation(); @@ -703,6 +744,7 @@ export function ServerBrowserSurface(props: {
event.preventDefault()} - onCut={(event) => event.preventDefault()} + onCopy={(event) => handleClipboard(COPY, event)} + onCut={(event) => handleClipboard(CUT, event)} onPaste={(event) => { event.preventDefault(); const text = event.clipboardData.getData("text/plain"); diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 109776a091e94..9c4a6c803ff5b 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -24,7 +24,11 @@ import { setActivePreviewTab, updatePreviewServerSnapshot, } from "~/previewStateStore"; -import { selectSelectedRightPanelSurface, useRightPanelStore } from "~/rightPanelStore"; +import { + rightPanelHostRef, + selectSelectedRightPanelSurface, + useRightPanelStore, +} from "~/rightPanelStore"; import { resolveBrowserDefaults } from "./browserDefaults"; @@ -57,23 +61,25 @@ export async function openUrlInPreview(input: { readonly profileId?: PreviewOpenInput["profileId"]; readonly background?: boolean; }): Promise> { + // Read before any await: a side chat pane that unmounts meanwhile drops its host mapping. + const threadRef = rightPanelHostRef(input.threadRef); const defaults = await resolveBrowserDefaults().catch( (cause: unknown) => new BrowserSettingsReadError({ cause }), ); if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } - const runtime = previewRuntimeFor(input.threadRef.environmentId); - const previousActiveTabId = readThreadPreviewState(input.threadRef).activeTabId; + const runtime = previewRuntimeFor(threadRef.environmentId); + const previousActiveTabId = readThreadPreviewState(threadRef).activeTabId; // The server changes the active tab, not panel selection; selection changes come from the user. const selectedSurface = () => - selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, input.threadRef) - ?.id ?? null; + selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, threadRef)?.id ?? + null; const surfaceBeforeOpen = selectedSurface(); const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, + environmentId: threadRef.environmentId, input: { - threadId: input.threadRef.threadId, + threadId: threadRef.threadId, url: input.url, viewport: defaults.viewport, profileId: input.profileId ?? defaults.profileId, @@ -81,21 +87,21 @@ export async function openUrlInPreview(input: { }, }); return mapAtomCommandResult(result, (snapshot) => { - rememberPreviewUrl(input.threadRef, input.url); + rememberPreviewUrl(threadRef, input.url); if (input.background) { - updatePreviewServerSnapshot(input.threadRef, snapshot); + updatePreviewServerSnapshot(threadRef, snapshot); // Restore focus unless the user picked a tab while opening this one. if ( previousActiveTabId && - readThreadPreviewState(input.threadRef).activeTabId === snapshot.tabId && + readThreadPreviewState(threadRef).activeTabId === snapshot.tabId && selectedSurface() === surfaceBeforeOpen ) { - setActivePreviewTab(input.threadRef, previousActiveTabId); + setActivePreviewTab(threadRef, previousActiveTabId); } return; } - applyPreviewServerSnapshot(input.threadRef, snapshot); - useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + applyPreviewServerSnapshot(threadRef, snapshot); + useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); }); } @@ -124,6 +130,7 @@ export async function openFileInPreview(input: { ), ); } + const hostRef = rightPanelHostRef(input.threadRef); const insideWorkspace = mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined; const assetResult = await input.createAssetUrl({ @@ -146,7 +153,7 @@ export async function openFileInPreview(input: { ); } return openUrlInPreview({ - threadRef: input.threadRef, + threadRef: hostRef, url: assetUrl, openPreview: input.openPreview, }); diff --git a/apps/web/src/closedViewStore.test.ts b/apps/web/src/closedViewStore.test.ts index 0223dfb9d5bd5..3e2cacc57e736 100644 --- a/apps/web/src/closedViewStore.test.ts +++ b/apps/web/src/closedViewStore.test.ts @@ -54,6 +54,12 @@ describe("closedViewStore", () => { threadRef: refA, surface: { kind: "pull-request", id: "pull-request:x", repository: "owner/repo", number: 1 }, }, + { + id: "bad", + kind: "panel-tab", + threadRef: refA, + surface: { kind: "side-chat", id: "side-chat:x" }, + }, ])("discards incomplete saved views %j while retaining valid history", async (entry) => { const id = useClosedViewStore.getState().remember(diff(refA)); const { storage, name } = useClosedViewStore.persist.getOptions(); @@ -85,6 +91,11 @@ describe("closedViewStore", () => { it("removes private metadata and malformed entries from history saved by earlier versions", async () => { const store = useClosedViewStore.getState(); + const sideChatId = store.remember({ + kind: "panel-tab", + threadRef: refA, + surface: { id: "side-chat:side", kind: "side-chat", threadId: ThreadId.make("side") }, + }); const publicId = store.remember(diff(refA)); const browserId = store.remember({ kind: "browser", @@ -115,6 +126,7 @@ describe("closedViewStore", () => { expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([ browserId, publicId, + sideChatId, ]); const saved = JSON.stringify(await storage!.getItem(name!)); expect(saved).not.toContain("private.example"); diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index 76fee83a83d08..b5db7bd741bef 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -83,6 +83,8 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { typeof surface.target.name === "string" && (surface.target.platform === "ios" || surface.target.platform === "android"))) ); + case "side-chat": + return typeof surface.threadId === "string" && surface.id === `side-chat:${surface.threadId}`; case "pull-request": return ( typeof surface.projectId === "string" && diff --git a/apps/web/src/components/AppRouteModal.browser.test.tsx b/apps/web/src/components/AppRouteModal.browser.test.tsx new file mode 100644 index 0000000000000..cb31eeca45ab0 --- /dev/null +++ b/apps/web/src/components/AppRouteModal.browser.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { useCommitOnBlur } from "../hooks/useCommitOnBlur"; +import { AppRouteModal } from "./AppRouteModal"; + +function CommitOnBlurField({ onCommit }: { onCommit: (next: string) => void }) { + return ; +} + +describe("AppRouteModal", () => { + it("commits a focused field when Esc closes it", async () => { + const onCommit = vi.fn(); + await using _screen = await render( + + + , + { router: "/settings/general" }, + ); + + await page.getByRole("textbox", { name: "Instructions" }).fill("Keep titles short"); + await userEvent.keyboard("{Escape}"); + + expect(onCommit).toHaveBeenCalledWith("Keep titles short"); + }); +}); diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx index 89e676da2e3c3..55d7c4112f968 100644 --- a/apps/web/src/components/AppRouteModal.tsx +++ b/apps/web/src/components/AppRouteModal.tsx @@ -27,7 +27,12 @@ export function AppRouteModal({ label, children }: { label: string; children: Re // Portaled layers outside this tree (toasts, Clerk, confirm dialogs) are not backdrop presses. disablePointerDismissal onOpenChange={(nextOpen) => { - if (!nextOpen) setOpen(false); + if (nextOpen) return; + // Closing unmounts a focused field without a blur, so commit-on-blur rows would + // drop what was typed. Blur first so they commit. + const focused = document.activeElement; + if (focused instanceof HTMLElement && popupRef.current?.contains(focused)) focused.blur(); + setOpen(false); }} onOpenChangeComplete={(nextOpen) => { if (!nextOpen) void closeAppModal(); diff --git a/apps/web/src/components/BackgroundThreadAnnouncer.tsx b/apps/web/src/components/BackgroundThreadAnnouncer.tsx index 82c280e6e56fe..3176c8132eed9 100644 --- a/apps/web/src/components/BackgroundThreadAnnouncer.tsx +++ b/apps/web/src/components/BackgroundThreadAnnouncer.tsx @@ -1,5 +1,6 @@ import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { isListedThreadLineage } from "@t3tools/contracts"; import type { AnyRouter } from "@tanstack/react-router"; import type { AtomRegistry } from "effect/reactivity"; @@ -42,9 +43,8 @@ export function startBackgroundThreadAnnouncements( const now = new Date().toISOString(); const snapshots: BackgroundThreadSnapshot[] = []; for (const thread of threads) { - // Subagents have no row of their own; their requests surface on the parent. - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") - continue; + // Subagents and side chats have no row of their own; their requests surface on the parent. + if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) continue; if (!liveEnvironmentIds.has(thread.environmentId)) continue; snapshots.push({ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index eabe4cc413c37..140cd6459c194 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -291,7 +291,7 @@ const RunContextSelector = memo(function RunContextSelector({ return ( {activeWorktreePath ? ( diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 7cdc175189699..817b0ee2d61ca 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -71,7 +71,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (!canSwitch && onAddToMachine === undefined) { const lockedRow = ( ({ 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("suppresses private link images while preserving public links across updates", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e02ab7eea35c0..0ed8511277938 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -208,6 +208,7 @@ import { import { MarkdownExternalLinkContent } from "./chat/MarkdownExternalLink"; 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"; @@ -242,6 +243,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 @@ -393,11 +395,13 @@ const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ ...CHAT_MARKDOWN_REHYPE_PLUGINS, rehypeHeadingIds, rehypeTextDirection, + rehypeChipPunctuation, ] satisfies NonNullable; const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [ rehypeHeadingIds, rehypeTextDirection, + rehypeChipPunctuation, ] satisfies NonNullable; function markdownUrlTransform(href: string) { @@ -1339,7 +1343,8 @@ const CHAT_MARKDOWN_COMPONENTS = { chip ) : ( - {children} {chip} + {children} + {chip} ); }, @@ -1659,6 +1664,18 @@ const CHAT_MARKDOWN_REMARK_PLUGIN_SETS = { 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> @@ -1747,15 +1764,11 @@ function ChatMarkdown({ ? "breaks" : "plain" ]; - // Extra and incremental plugins are per instance, so their output is not shared. - const uncachedPlugins = extraRemarkPlugins.length > 0 || incrementalParsing; - const remarkPlugins = uncachedPlugins - ? [ - ...sharedRemarkPlugins, - ...extraRemarkPlugins, - ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), - ] - : sharedRemarkPlugins; + // The incremental parser keeps per-instance state, so its output is not shared. + const uncachedPlugins = incrementalParsing; + const remarkPlugins = incrementalParsing + ? [...sharedRemarkPlugins, ...extraRemarkPlugins, createIncrementalMarkdownPlugin()] + : withExtraPlugins(sharedRemarkPlugins, extraRemarkPlugins); const blocks = streamingBlocks ?? [text]; return ( @@ -1783,7 +1796,8 @@ function ChatMarkdown({ : CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS } shared={!uncachedPlugins} - cache={!props.isStreaming} + // Every block before the last is finished, so its parse can be reused. + cache={!props.isStreaming || index < blocks.length - 1} /> ))} 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 fedbb4048d625..8fe18aee34e47 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -536,6 +536,7 @@ export function resolveComposerProviderSelection(input: { : requestedInstanceId; return { selectedProviderEntry, + requestedInstanceId, requestedDriverKind, lockedContinuationGroupKey, unavailableProviderInstanceId, @@ -786,15 +787,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 ca873d336e15f..8e65127830323 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -64,6 +64,7 @@ import { type AssistantCitation, type ChatFileAttachment, isProviderNativeSubagentThread, + orchestrationV2WorktreePending, type EnvironmentId, type MessageId, type ModelSelection, @@ -189,6 +190,7 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; import { newMessageId } from "~/lib/utils"; +import { isInSideChatPane, type SideChatParentPanel } from "~/lib/sideChatPane"; import { NO_PROVIDER_MODEL_SELECTION, providerInstanceEntriesFor, @@ -233,7 +235,7 @@ import { primaryServerKeybindingsAtom, serverEnvironment, } from "../state/server"; -import { threadEnvironment, useThreadFamily } from "../state/threads"; +import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { useWorkspaceRepositories } from "../hooks/useWorkspaceRepositories"; @@ -280,6 +282,7 @@ 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"; @@ -399,6 +402,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; } @@ -408,6 +413,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + embedded?: never; routeKind: "draft"; draftId: DraftId; }; @@ -422,7 +428,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; @@ -483,14 +491,6 @@ export default function ChatView(props: ChatViewProps) { }); const serverThreadProjection = useQuietThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; - // Subagent rows, an open child's own row and its ancestors come from the family when the shell - // leaves idle children out. Every source agrees on the root, so the key never flips. - useThreadFamily( - routeKind === "server" ? environmentId : null, - serverThreadShell?.lineage.rootThreadId ?? - serverProjection?.thread.lineage.rootThreadId ?? - null, - ); // Only the shell-less fallback needs these; with a shell, the shell carries them. const fallbackDetailRef = serverThreadShell === null ? routeThreadDetailRef : null; const fallbackUpdatedAt = useThreadUpdatedAt(fallbackDetailRef); @@ -898,7 +898,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", @@ -1125,7 +1147,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; // Fork: the explorer and opened files dock left of the chat when the setting says so. const filesDockActive = @@ -1807,14 +1831,16 @@ export default function ChatView(props: ChatViewProps) { providerSubagentModels, reportedModelSelection, ); - const mountComposerContextStrip = shouldShowComposerContextStrip({ - isDraftHeroState, - persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null && !showProviderSubagentBar, - isGitRepo: hasGitSurfaces, - showEnvironmentIndicator: showComposerEnvironmentIndicator, - hostsRestingComposerControls: routeKind === "server", - }); + const mountComposerContextStrip = + !embedded && + shouldShowComposerContextStrip({ + isDraftHeroState, + persistInActiveThreads: settings.persistComposerContextStrip, + hasActiveProject: activeProject !== null && !showProviderSubagentBar, + isGitRepo: hasGitSurfaces, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + hostsRestingComposerControls: routeKind === "server", + }); // Fork: shown whenever mounted, like the model strip, since it always holds // the usage badge. Hiding it when the model controls did not fit changed the // composer's width, which made them fit again, and the strip flickered. @@ -1917,7 +1943,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(); @@ -2075,8 +2104,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, @@ -2210,18 +2239,18 @@ 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(() => { - if (!activeThread?.id || terminalUiState.terminalOpen) return; + if (embedded || !activeThread?.id || terminalUiState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { focusComposerStable(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposerStable, terminalUiState.terminalOpen]); + }, [activeThread?.id, embedded, focusComposerStable, terminalUiState.terminalOpen]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; + if (embedded || !activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; let frame: number | null = null; const onWindowFocus = () => { if (frame !== null) window.cancelAnimationFrame(frame); @@ -2230,7 +2259,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(); + } }); }); }; @@ -2239,7 +2273,13 @@ export default function ChatView(props: ChatViewProps) { window.removeEventListener("focus", onWindowFocus); if (frame !== null) window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposerStable, isMobileViewport, terminalUiState.terminalOpen]); + }, [ + activeThread?.id, + embedded, + focusComposerStable, + isMobileViewport, + terminalUiState.terminalOpen, + ]); // Promotion drops draftId while a send may still be dispatching, so key by thread. const [localDispatchThreadId, setLocalDispatchThreadId] = useState(threadId); @@ -2470,6 +2510,7 @@ export default function ChatView(props: ChatViewProps) { threadPanelPresentation, isServerThread, isGitRepo: hasGitSurfaces, + embedded, browserAvailable, canOperatePreview, diffOpen, @@ -2534,7 +2575,6 @@ export default function ChatView(props: ChatViewProps) { clearUsageLimitsFor, beginLocalDispatch, resetLocalDispatch, - scheduleComposerFocus, scrollToEnd, persistThreadSettingsForNextTurn, }); @@ -2756,8 +2796,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, @@ -2766,7 +2806,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), @@ -2787,6 +2829,8 @@ export default function ChatView(props: ChatViewProps) { useChatShortcuts({ enabled: activeThreadId !== null && !backgroundThread, + embedded, + composerElement: composerOverlayElement, keybindings, scriptKeybindings, getShortcutContext, @@ -2841,13 +2885,56 @@ 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); + usePasteToFocusComposer(embedded ? null : activeThreadId, composerRef); + // 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 { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } = useTimelineRewind({ environmentId, @@ -3012,6 +3099,8 @@ export default function ChatView(props: ChatViewProps) { anchorNewTurn, acknowledgeActiveThreadWoke, handleInteractionModeChange, + onSideChat: sideChat.available ? sendSideChatStable : undefined, + sideChatDisabledReason, handleNewThreadInActiveProject, persistThreadSettingsForNextTurn, }); @@ -3150,10 +3239,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, @@ -3238,7 +3324,7 @@ export default function ChatView(props: ChatViewProps) { ); if (!activeThread) { - return ; + return embedded ? null : ; } // Fork: shared with the left files dock, which renders the same file surfaces. @@ -3251,6 +3337,10 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext: getShortcutContextStable, onSendAnnotation: sendPreviewAnnotationStable, onCloseSurface: closeRightPanelSurface, + sideChatParentPanel: { + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }, terminal: { launchContext: activeTerminalLaunchContext ?? null, keybindings, @@ -3357,13 +3447,15 @@ 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} - {filesDockActive && activeThreadRef && activeThreadKey ? ( + {embedded ? null : ( + + )} + {rightPanelControlsAtRoot && !embedded ? panelLayoutControls : null} + {filesDockActive && !embedded && activeThreadRef && activeThreadKey ? ( -
- {isElectron && rightPanelControlsAtRoot ? ( - + {isElectron && rightPanelControlsAtRoot ? ( + + ) : null} + {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} + {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} + {filesDockActive && activeThreadRef ? ( + + ) : null} + - ) : null} - {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} - {filesDockActive && activeThreadRef ? ( - - ) : 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 + ) && !filesDockShowsWorkspaceFile + } + gitCwd={gitCwd} + isGitRepo={isGitRepo} + workspaceRepositories={workspaceRepositories} + 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 && hasGitSurfaces + ? { onOpenChanges: openChangesFromThreadPanelStable } + : {})} + 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 : } diff --git a/apps/web/src/components/CommandPalette.composition.browser.test.tsx b/apps/web/src/components/CommandPalette.composition.browser.test.tsx new file mode 100644 index 0000000000000..4787885905985 --- /dev/null +++ b/apps/web/src/components/CommandPalette.composition.browser.test.tsx @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { act, render } from "../../test/render"; +import { CommandPalette } from "./CommandPalette"; +import { openCommandPalette } from "../commandPaletteBus"; +import { primaryServerKeybindingsAtom } from "../state/server"; + +vi.mock("@effect/atom-react", async (original) => ({ + ...(await original()), + useAtomValue: (atom: unknown) => + atom === primaryServerKeybindingsAtom ? DEFAULT_RESOLVED_KEYBINDINGS : [], +})); +vi.mock("../hooks/useTheme", () => ({ + 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.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 7ec1a3e9ac1e3..c034c7f4131c1 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -3,7 +3,9 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools import type { Thread } from "../types"; import { makeThreadFixture } from "../test-fixtures"; import { + newThreadNoProjectIndex, buildNestedSearchItems, + resolveCommandPaletteProject, buildThreadActionItems, buildLinkedThreadActionItems, filterPinnedBrowseEntries, @@ -918,3 +920,44 @@ 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(); +}); + +describe("newThreadNoProjectIndex", () => { + const entry = (pinned = false, isPreferred = false, isScratch = false) => ({ + pinned, + isPreferred, + isScratch, + }); + + it("goes after the pinned projects and the first unpinned one", () => { + expect(newThreadNoProjectIndex([entry(true), entry(true), entry(false, true), entry()])).toBe( + 3, + ); + expect(newThreadNoProjectIndex([entry(false, true), entry(), entry(false, false, true)])).toBe( + 1, + ); + }); + + it("keeps a pinned or current scratch project in its own place", () => { + expect(newThreadNoProjectIndex([entry(false, true, true), entry()])).toBe(0); + expect(newThreadNoProjectIndex([entry(true), entry(true, false, true), entry()])).toBe(1); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 6102028889369..cc8fd9091ded8 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"; @@ -207,6 +208,14 @@ export interface CommandPaletteActionItem extends CommandPaletteItem { readonly kind: "action"; readonly keepOpen?: boolean; readonly run: () => Promise; + /** A pin toggle on the row, also reached with the pin shortcut while the row is highlighted. */ + readonly pin?: CommandPaletteItemPin; +} + +export interface CommandPaletteItemPin { + readonly name: string; + readonly pinned: boolean; + readonly toggle: () => void; } export interface CommandPaletteSubmenuItem extends CommandPaletteItem { @@ -363,24 +372,29 @@ export function buildProjectActionItems(input: { searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; renderDescription?: (project: CommandPaletteProject) => ReactNode; shortcutCommand?: KeybindingCommand; + pin?: (project: CommandPaletteProject) => CommandPaletteItemPin | null; }): CommandPaletteActionItem[] { - return input.projects.map((project) => ({ - kind: "action", - value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, - searchTerms: [ - project.displayName, - project.title, - project.workspaceRoot, - ...(input.searchTerms?.(project) ?? []), - ], - title: project.displayName, - description: input.renderDescription?.(project) ?? project.workspaceRoot, - icon: input.icon(project), - ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), - run: async () => { - await input.runProject(project); - }, - })); + return input.projects.map((project) => { + const pin = input.pin?.(project) ?? null; + return { + kind: "action", + value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, + searchTerms: [ + project.displayName, + project.title, + project.workspaceRoot, + ...(input.searchTerms?.(project) ?? []), + ], + title: project.displayName, + description: input.renderDescription?.(project) ?? project.workspaceRoot, + icon: input.icon(project), + ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), + ...(pin ? { pin } : {}), + run: async () => { + await input.runProject(project); + }, + }; + }); } export type BuildThreadActionItemsThread = Pick< @@ -420,7 +434,7 @@ export function buildThreadActionItems thread.archivedAt === null && !isSidebarSubagentThread(thread), + (thread) => thread.archivedAt === null && !isSidebarHiddenThread(thread), ); return visibleThreads.map((thread) => { @@ -860,3 +874,33 @@ 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; +} + +/** + * Where "No project" goes among the new-thread picker's real projects: at the + * scratch project's own place when it is pinned or current, else after the + * pinned projects and the first unpinned one. + */ +export function newThreadNoProjectIndex( + entries: ReadonlyArray<{ + readonly isScratch: boolean; + readonly pinned: boolean; + readonly isPreferred: boolean; + }>, +): number { + const projects = entries.filter((entry) => !entry.isScratch); + const scratchAt = entries.findIndex((entry) => entry.isScratch); + const scratch = entries[scratchAt]; + if (scratch && (scratch.pinned || scratch.isPreferred)) { + return entries.slice(0, scratchAt).filter((entry) => !entry.isScratch).length; + } + const pinnedCount = projects.filter((entry) => entry.pinned).length; + return Math.min(pinnedCount + 1, projects.length); +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 50c3452404344..486d80a988666 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -68,6 +68,8 @@ import { RotateCcwIcon, SettingsIcon, MonitorIcon, + PinIcon, + PinOffIcon, SquarePenIcon, TextSearchIcon, UserPlusIcon, @@ -111,11 +113,14 @@ import { threadEnvironment } from "../state/threads"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; +import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; import { useStartTriage } from "../hooks/useStartTriage"; import { pickTriageEnvironment } from "../lib/triageTarget"; import { useNewProject } from "../hooks/useNewProject"; +import { useOrderWhileOpen } from "../hooks/useOrderWhileOpen"; +import { reportProjectFailure, useProjectPin } from "../hooks/useProjectGroupUpdate"; import { useEnvironmentProviders, useEnvironments, @@ -159,6 +164,7 @@ import { resolveWslProjectSelection, } from "../wslPaths"; import { + resolveCommandPaletteProject, ADDON_ICON_CLASS, browseInputEndPaddingClass, buildBrowseGroups, @@ -166,6 +172,7 @@ import { buildLinkedThreadActionItems, buildCommandPaletteRows, enumerateCommandPaletteItems, + newThreadNoProjectIndex, findHighlightedCommandPaletteItem, type CommandPaletteActionItem, type CommandPaletteOpenIntent, @@ -235,6 +242,7 @@ import { suggestAppAccessForRemote } from "./sourceControlAppAccessToast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { buildSidebarProjectPickerEntries } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; @@ -269,6 +277,8 @@ function openIntentView(intent: CommandPaletteOpenIntent | null): CommandPalette } } +const NEW_THREAD_NO_PROJECT_VALUE = "new-thread-in:no-project"; + export function CommandPalette({ children }: { children: ReactNode }) { useCommandPaletteMount(); const state = useCommandPaletteStore(); @@ -407,6 +417,9 @@ function OpenCommandPaletteDialog(props: { const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); + const updateProject = useOrchestrationCommand(projectEnvironment.update, { + label: "project unarchive", + }); const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject(); const startTriage = useStartTriage(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { @@ -499,7 +512,7 @@ function OpenCommandPaletteDialog(props: { handleNewThread, }); const projectPickerEntries = buildSidebarProjectPickerEntries({ - groups: projectGroups, + groups: pickableProjectGroups(projectGroups), preferredProjectRef: contextualProjectRef, }); const pickerProjects = projectPickerEntries.map(({ group, targetProject }) => ({ @@ -512,6 +525,21 @@ function OpenCommandPaletteDialog(props: { group, ]), ); + const { canPinProjectGroup, toggleProjectGroupPin } = useProjectPin(); + // A row's target record can change when another machine's copy loads, so the kept + // order follows the project group instead. + const rowOrderKey = (prefix: string) => (item: CommandPaletteActionItem) => + projectGroupByTargetKey.get(item.value.slice(prefix.length + 1))?.projectKey ?? item.value; + const projectPin = (project: CommandPaletteProject) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + return group && canPinProjectGroup(group) + ? { + name: group.displayName, + pinned: group.pinned, + toggle: () => toggleProjectGroupPin(group), + } + : null; + }; const addProjectEnvironmentOptions = (() => { const options = environments @@ -611,7 +639,7 @@ function OpenCommandPaletteDialog(props: { return ensureBrowseDirectoryPath(baseDirectory); }; - const { projectCwdById, projectByKey, projectTitleById } = useProjectLookups(); + const { projectByKey } = useProjectLookups(); const activeThreadKey = activeThread ? scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)) @@ -637,9 +665,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 @@ -751,21 +782,28 @@ function OpenCommandPaletteDialog(props: { await handleNewThread(scopeProjectRef(project.environmentId, project.id)); }; - const projectSearchItems = buildProjectSearchItems({ - projects: pickerProjects, - projectGroupByTargetKey, - locationByEnvironmentId: projectEnvironmentLocationById, - runProject: openProjectFromSearch, - }); + // Pinning from a row keeps rows in place while the palette is open. + const projectSearchItems = useOrderWhileOpen( + buildProjectSearchItems({ + projects: pickerProjects, + projectGroupByTargetKey, + locationByEnvironmentId: projectEnvironmentLocationById, + runProject: openProjectFromSearch, + pin: projectPin, + }), + rowOrderKey("project"), + true, + ); - const projectThreadItems = (() => { - const isScratch = (project: CommandPaletteProject) => - isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); - const projectItems = enumerateCommandPaletteItems( - buildNewThreadInProjectItems({ + const openingOrderThreadItems = useOrderWhileOpen( + (() => { + const isScratch = (project: CommandPaletteProject) => + isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); + const projectItems = buildNewThreadInProjectItems({ projects: pickerProjects.filter((project) => !isScratch(project)), projectGroupByTargetKey, locationByEnvironmentId: projectEnvironmentLocationById, + pin: projectPin, runProject: async (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const contextualRefBelongsToGroup = @@ -781,25 +819,45 @@ function OpenCommandPaletteDialog(props: { : scopeProjectRef(project.environmentId, project.id), ); }, - }), - ); - if (scratchTargetEnvironmentId === null) return projectItems; + }); + if (scratchTargetEnvironmentId === null) return projectItems; - const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1; - return [ - ...projectItems.slice(0, noProjectIndex), - { - kind: "action" as const, - value: "new-thread-in:no-project", - searchTerms: ["no project", "without project", "none"], - title: "No project", - icon: , - shortcutCommand: "chat.newWithoutProject" as const, - run: () => startScratchThread(scratchTargetEnvironmentId), - }, - ...projectItems.slice(noProjectIndex), - ]; - })(); + const noProjectIndex = newThreadNoProjectIndex( + projectPickerEntries.map(({ group, targetProject, isPreferred }) => ({ + isScratch: isScratchProject( + targetProject, + scratchWorkspaceRootFor(targetProject.environmentId), + ), + pinned: group.pinned, + isPreferred, + })), + ); + return [ + ...projectItems.slice(0, noProjectIndex), + { + kind: "action" as const, + value: NEW_THREAD_NO_PROJECT_VALUE, + searchTerms: ["no project", "without project", "none"], + title: "No project", + icon: , + shortcutCommand: "chat.newWithoutProject" as const, + run: () => startScratchThread(scratchTargetEnvironmentId), + }, + ...projectItems.slice(noProjectIndex), + ]; + })(), + rowOrderKey("new-thread-in"), + true, + ); + // Jump shortcuts follow the rows as shown, so a pin cannot move a number to another row. + const projectJumpItems = new Map( + enumerateCommandPaletteItems( + openingOrderThreadItems.filter((item) => item.value !== NEW_THREAD_NO_PROJECT_VALUE), + ).map((item) => [item.value, item] as const), + ); + const projectThreadItems = openingOrderThreadItems.map( + (item) => projectJumpItems.get(item.value) ?? item, + ); const { threadItems: allThreadItems, @@ -1155,7 +1213,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({ @@ -1415,7 +1474,7 @@ function OpenCommandPaletteDialog(props: { }); } - actionItems.push({ + const addProjectAction: CommandPaletteActionItem = { kind: "action", value: "action:add-project", searchTerms: [ @@ -1443,7 +1502,8 @@ function OpenCommandPaletteDialog(props: { run: async () => { openAddProjectFlow(); }, - }); + }; + actionItems.push(addProjectAction); if (wslAddProjectEnvironmentOption) { actionItems.push({ @@ -1531,14 +1591,33 @@ function OpenCommandPaletteDialog(props: { }, }); - const contextualProjectGroup = + // Every group, so an archived project's thread still opens its own settings. + const currentProjectGroup = (contextualProjectRef - ? projectGroupByTargetKey.get( - `${contextualProjectRef.environmentId}:${contextualProjectRef.projectId}`, + ? projectGroups.find((group) => + group.memberProjectRefs.some( + (ref) => + ref.environmentId === contextualProjectRef.environmentId && + ref.projectId === contextualProjectRef.projectId, + ), ) - : null) ?? - projectGroups[0] ?? - null; + : null) ?? null; + if (currentProjectGroup && canPinProjectGroup(currentProjectGroup)) { + actionItems.push({ + kind: "action", + value: "action:project-pin", + searchTerms: ["pin", "unpin", "project", "favorite"], + title: currentProjectGroup.pinned ? "Unpin project" : "Pin project", + description: currentProjectGroup.displayName, + icon: currentProjectGroup.pinned ? ( + + ) : ( + + ), + run: async () => toggleProjectGroupPin(currentProjectGroup), + }); + } + const contextualProjectGroup = currentProjectGroup ?? projectGroups[0] ?? null; if (contextualProjectGroup) { actionItems.push({ kind: "action", @@ -1603,7 +1682,14 @@ function OpenCommandPaletteDialog(props: { : currentView?.groups[0]?.value === ADD_PROJECT_ENTRY_GROUP ? addProjectEntryGroups() : currentView?.groups[0]?.value === NEW_THREAD_IN_GROUP - ? [{ value: NEW_THREAD_IN_GROUP, label: "Projects", items: projectThreadItems }] + ? [ + { + value: NEW_THREAD_IN_GROUP, + label: "Projects", + // Every project archived and no "No project" home leaves only adding one. + items: projectThreadItems.length > 0 ? projectThreadItems : [addProjectAction], + }, + ] : currentView?.groups[0]?.value === "themes" ? changeThemeItem.groups : currentView?.groups[0]?.value === "appearance" @@ -1695,6 +1781,13 @@ function OpenCommandPaletteDialog(props: { cwd, ); if (existing) { + // Adding an archived project's folder again brings the project back. + if (existing.archivedAt != null) { + void updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }).then((result) => reportProjectFailure("Failed to unarchive project", result)); + } const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === existing.environmentId), existing.id, @@ -2287,10 +2380,29 @@ 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 }, }); + if (command === "thread.pin") { + const highlightedItem = findHighlightedCommandPaletteItem( + displayedGroups, + highlightedItemValue ?? + (autoHighlightsFirstRow ? (resultRows.itemValues[0] ?? null) : null), + ); + if (highlightedItem?.kind === "action" && highlightedItem.pin) { + event.preventDefault(); + event.stopPropagation(); + highlightedItem.pin.toggle(); + return; + } + } // Global new-thread handlers ignore shortcuts while the palette is open. const isJump = threadJumpIndexFromCommand(command ?? "") !== null; const isNewThread = 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/CommandPaletteProjectItems.tsx b/apps/web/src/components/CommandPaletteProjectItems.tsx index 90373413be4fa..2a3d466474d2e 100644 --- a/apps/web/src/components/CommandPaletteProjectItems.tsx +++ b/apps/web/src/components/CommandPaletteProjectItems.tsx @@ -5,6 +5,7 @@ import type { Project } from "../types"; import { buildCommandPaletteProjectMetadata, buildProjectActionItems, + type CommandPaletteItemPin, type CommandPaletteProject, ITEM_ICON_CLASS, } from "./CommandPalette.logic"; @@ -29,6 +30,7 @@ interface ProjectItemsInput { readonly projectGroupByTargetKey: ReadonlyMap; readonly locationByEnvironmentId: ReadonlyMap; readonly runProject: (project: CommandPaletteProject) => Promise; + readonly pin?: (project: CommandPaletteProject) => CommandPaletteItemPin | null; } function projectFaviconIcon(project: Project) { @@ -66,6 +68,7 @@ export function buildProjectSearchItems(input: ProjectItemsInput) { }, icon: projectFaviconIcon, runProject: input.runProject, + ...(input.pin ? { pin: input.pin } : {}), }); } @@ -90,6 +93,7 @@ export function buildNewThreadInProjectItems(input: ProjectItemsInput) { ), icon: projectFaviconIcon, runProject: input.runProject, + ...(input.pin ? { pin: input.pin } : {}), }); } diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index c481d57d5fc8c..e3cc3a3791afa 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -22,6 +22,7 @@ import { CommandShortcut, } from "./ui/command"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; +import { ProjectPinButton } from "./ProjectPinButton"; import { SearchMatchHintLine, ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; interface CommandPaletteResultsProps { @@ -268,6 +269,14 @@ function CommandPaletteResultRow(props: { )} {props.item.titleTrailingContent} + {props.item.kind === "action" && props.item.pin ? ( + + ) : null} {props.item.timestamp ? ( {props.item.timestamp} 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 6576861657959..cb9207c7eb0a7 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -95,6 +95,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 7a77428a13b59..ffd606c012da6 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -229,7 +229,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/PersistentThreadTerminals.tsx b/apps/web/src/components/PersistentThreadTerminals.tsx index 08114b79379cd..52c8db8fcc006 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/PersistentThreadTerminals.tsx @@ -301,7 +301,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/ProjectPinButton.tsx b/apps/web/src/components/ProjectPinButton.tsx new file mode 100644 index 0000000000000..a8bd7d5ee8e99 --- /dev/null +++ b/apps/web/src/components/ProjectPinButton.tsx @@ -0,0 +1,67 @@ +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { PinIcon, PinOffIcon } from "lucide-react"; +import type { KeyboardEvent } from "react"; + +import { resolveShortcutCommand } from "../keybindings"; + +import { cn } from "~/lib/utils"; +import { Button } from "./ui/button"; + +/** The thread pin shortcut, which pins the highlighted row in a project list. */ +export function isProjectPinShortcut( + event: KeyboardEvent, + keybindings: ResolvedKeybindingsConfig, +): boolean { + return ( + resolveShortcutCommand(event.nativeEvent, keybindings, { + platform: navigator.platform, + context: { modelPickerOpen: false, editableFocus: true }, + }) === "thread.pin" + ); +} + +/** + * Pin toggle for a project row in a menu or list. A pinned project always shows its pin; + * an unpinned one shows it only on the highlighted row. Keyboard users get a shortcut + * from the list that hosts it, so the button stays out of the tab order. + */ +export function ProjectPinButton(props: { + readonly name: string; + readonly pinned: boolean; + readonly onToggle: () => void; + readonly shortcutLabel?: string | null; +}) { + const action = `${props.pinned ? "Unpin" : "Pin"} ${props.name}`; + return ( + + + + ); +} 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 067927dd4e68a..5bc3da73ff6f5 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 { @@ -33,6 +43,7 @@ import { FileDiff, Files, Globe2, + MessagesSquare, Plus, TerminalSquare, } from "lucide-react"; @@ -137,6 +148,8 @@ export interface RightPanelTabsProps { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -144,6 +157,9 @@ export interface RightPanelTabsProps { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + sideChatAvailable?: boolean; + /** Overrides the default reason while side chats are available but cannot start. */ + sideChatDisabledReason?: string | null; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -173,6 +189,7 @@ const SURFACE_DISABLED_REASONS = { pullRequest: "This thread's branch has no pull request yet.", pullRequests: "No linked pull requests are available for this 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. @@ -197,6 +214,7 @@ const SURFACE_UNAVAILABLE_HINTS = { pullRequest: "No pull request on this branch yet.", pullRequests: "No linked pull requests available.", device: "Available from a thread.", + sideChat: "Available after the first message.", } as const; type TabContextMenuAction = @@ -337,6 +355,8 @@ function RightPanelEmptyState(props: { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -344,6 +364,8 @@ function RightPanelEmptyState(props: { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + sideChatAvailable: boolean; + sideChatDisabledReason: string | null; }) { const [highlight, setHighlight] = useState(-1); @@ -405,6 +427,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]; @@ -590,6 +625,8 @@ function surfaceTitle( return "Pull requests"; 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"; @@ -678,6 +715,8 @@ function SurfaceIcon({ ); case "pull-requests": return ; + case "side-chat": + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -689,6 +728,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, @@ -846,7 +920,6 @@ function RightPanelTab( ) { const { surface, - title, audio, audioRuntimeTabId, onActivate, @@ -860,6 +933,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)}> @@ -929,6 +1009,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} @@ -1129,6 +1210,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) => { @@ -1535,6 +1628,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddPullRequest={props.onAddPullRequest} onAddPullRequests={props.onAddPullRequests} onAddDevice={props.onAddDevice} + onAddSideChat={props.onAddSideChat} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} @@ -1542,6 +1636,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { pullRequestAvailable={props.pullRequestAvailable} pullRequestsAvailable={props.pullRequestsAvailable} deviceAvailable={props.deviceAvailable} + 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 9fd67a66060fd..e1d18b1f0fa55 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { + shouldCreateNewThreadInCurrentProject, archiveSelectedThreadEntries, deleteSelectedThreadEntries, pruneDisabledEnvironmentIds, @@ -24,6 +25,7 @@ import { sortInboxThreadsByReturn, resolveSidebarDropTarget, planSidebarThreadDrop, + resolveSidebarReorderableThreadKeys, sortPinnedThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -38,6 +40,11 @@ import { resolveSidebarFolderDot, sidebarFolderExpansionKeys, } from "./Sidebar.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"; @@ -1344,6 +1351,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"], @@ -1504,6 +1745,12 @@ describe("groupSidebarThreadsByProject", () => { projectKey: "fleet", memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: fleet }], }, + { + projectKey: "notes", + memberProjectRefs: [ + { environmentId: localEnvironmentId, projectId: ProjectId.make("notes") }, + ], + }, ]; const threads = [ @@ -1511,17 +1758,23 @@ describe("groupSidebarThreadsByProject", () => { thread("orphan", localEnvironmentId, ProjectId.make("deleted")), thread("b", remoteEnvironmentId, t3codeRemote), ]; - const summarize = (keepEmpty: boolean) => + const summarize = (keepEmpty: (group: (typeof groups)[number]) => boolean) => groupSidebarThreadsByProject(groups, threads, keepEmpty).map((folder) => ({ projectKey: folder.group.projectKey, threads: folder.threads.map((entry) => entry.id), })); - expect(summarize(true)).toEqual([ + expect(summarize(() => true)).toEqual([ + { projectKey: "t3code", threads: ["a", "b"] }, + { projectKey: "fleet", threads: [] }, + { projectKey: "notes", threads: [] }, + ]); + expect(summarize(() => false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); + // A pinned project keeps its empty folder while other empty projects drop out. + expect(summarize((group) => group.projectKey === "fleet")).toEqual([ { projectKey: "t3code", threads: ["a", "b"] }, { projectKey: "fleet", threads: [] }, ]); - expect(summarize(false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); }); }); @@ -1561,3 +1814,13 @@ describe("resolveSidebarFolderDot", () => { expect(resolveSidebarFolderDot([unread, limited])).toBe("attention"); }); }); + +describe("shouldCreateNewThreadInCurrentProject", () => { + it("opens the picker when several projects are open, or only archived ones exist", () => { + expect(shouldCreateNewThreadInCurrentProject(false, 2, 3)).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, 0, 2)).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, 1, 3)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(false, 0, 0)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(true, 2, 2)).toBe(true); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 474f252a5fcca..39b229d7ae3a0 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,3 +1,5 @@ +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"; @@ -14,7 +16,12 @@ import { type ThreadSearchShell, } from "@t3tools/client-runtime/state/thread-search"; import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; -import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + isListedThreadLineage, + type ContextMenuItem, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; import { @@ -309,6 +316,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; @@ -353,7 +386,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": { @@ -541,12 +595,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, @@ -557,7 +611,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); @@ -728,11 +782,13 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { return nodeClosest(parent, "a[href]") !== null; } +/** The "New thread in" picker opens unless there is one open project or none; then the current context decides. */ export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, + openProjectGroupCount: number, projectGroupCount: number, ): boolean { - return shiftKey || projectGroupCount <= 1; + return shiftKey || openProjectGroupCount === 1 || projectGroupCount === 0; } export function orderItemsByPreferredIds(input: { @@ -919,7 +975,7 @@ export function sidebarFolderExpansionKeys(group: { /** * Boring sidebar folders. Folders follow `groups` order and threads keep their - * input order. A project with no threads gets a folder only with `keepEmpty`. + * input order. A project with no threads gets a folder only when `keepEmpty` says so. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< @@ -933,7 +989,7 @@ export function groupSidebarThreadsByProject< >( groups: readonly TGroup[], threads: readonly TThread[], - keepEmpty: boolean, + keepEmpty: (group: TGroup) => boolean, ): Array<{ group: TGroup; threads: TThread[] }> { const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); const folderByProject = new Map(); @@ -945,7 +1001,7 @@ export function groupSidebarThreadsByProject< for (const thread of threads) { folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); } - return keepEmpty ? folders : folders.filter((folder) => folder.threads.length > 0); + return folders.filter((folder) => folder.threads.length > 0 || keepEmpty(folder.group)); } export type SidebarV2TopStatusKind = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5373aaaaa7eb6..a08aaa1ad21e2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -104,7 +104,10 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isMacPlatform } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; -import { selectProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; +import { + pickableProjectGroups, + selectProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; import { projectGroupsSpanEnvironments, resolveSidebarProjectScopeKeys, @@ -122,6 +125,8 @@ import { useThreadActions, } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useOrderWhileOpen } from "../hooks/useOrderWhileOpen"; +import { useProjectPin } from "../hooks/useProjectGroupUpdate"; import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; @@ -192,6 +197,7 @@ import { resolveSidebarDropTarget, resolveSidebarDropVerb, resolveSidebarThreadSection, + resolveSidebarReorderableThreadKeys, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, @@ -233,6 +239,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { copyThreadReference } from "../lib/copyThreadReference"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { Button } from "./ui/button"; +import { isProjectPinShortcut, ProjectPinButton } from "./ProjectPinButton"; import { Combobox, ComboboxEmpty, @@ -346,6 +353,7 @@ interface SidebarThreadPartitionInput { /** Tasks linked environments run for a listed thread; they show under it. */ readonly delegatedUnderParent: ReadonlySet; readonly serverConfigs: ReadonlyMap; + readonly connectedEnvironmentIds: readonly EnvironmentId[]; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ @@ -407,20 +415,22 @@ function useSidebarThreads(input: { readonly scopedProjectKeys: ReadonlySet | null; readonly disabledEnvironmentIds: ReadonlySet; readonly serverConfigs: ReadonlyMap; + readonly connectedEnvironmentIds: readonly EnvironmentId[]; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly threads: readonly EnvironmentThreadShell[]; readonly delegatedUnderParent: ReadonlySet; readonly routeRowThreadKey: string | null; // Set in boring mode: the project groups to fold active threads into, in order. readonly folderGroups: readonly SidebarProjectSnapshot[] | null; - // Keeps folders with no active threads, so a project the user filtered to can start one. - readonly keepEmptyFolders: boolean; + // Keeps folders with no active threads, so a project the user filtered to or pinned can start one. + readonly keepEmptyFolders: (group: SidebarProjectSnapshot) => boolean; }) { const { projectScopeKey, scopedProjectKeys, disabledEnvironmentIds, serverConfigs, + connectedEnvironmentIds, optimisticDrop, threads, delegatedUnderParent, @@ -473,6 +483,7 @@ function useSidebarThreads(input: { disabledEnvironmentIds, delegatedUnderParent, serverConfigs, + connectedEnvironmentIds, optimisticDrop, workingShelfEnabled, sortActiveBySend: folderGroups !== null, @@ -629,15 +640,14 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { : active; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; - const draggable = new Set(); - const activeReorderable = new Set(); + const { draggableThreadKeys, activeReorderableThreadKeys } = resolveSidebarReorderableThreadKeys({ + threads: visible, + serverConfigs, + connectedEnvironmentIds: input.connectedEnvironmentIds, + }); for (const thread of visible) { const configLoaded = serverConfigs.has(thread.environmentId); const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - if (configLoaded) { - activeReorderable.add(threadKey); - draggable.add(threadKey); - } if (optimisticDrop?.key === threadKey) { const projected = applySidebarThreadDrop( thread, @@ -671,7 +681,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { ).push(thread); } } - // A loaded config gates dragging only; the sort must not depend on it, so web and mobile agree. + // Availability gates dragging only; sorting must stay consistent across clients. const sortedPinned = sortPinnedThreadsForSidebar(pinned); const sortedActive = sortActiveBySend ? sortWorkingThreadsBySend(active) @@ -687,8 +697,8 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { preferredIds: optimisticDrop.order, getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }), - draggableThreadKeys: draggable, - activeReorderableThreadKeys: activeReorderable, + draggableThreadKeys, + activeReorderableThreadKeys, activeThreads: optimisticDrop?.section !== "active" || optimisticDrop.order === null ? sortedActive @@ -822,7 +832,10 @@ function useSidebarLookups() { ); const projectScopeItems = [ { value: "all", label: "All projects" }, - ...projectGroups.map((project) => ({ value: project.projectKey, label: project.displayName })), + ...pickableProjectGroups(projectGroups).map((project) => ({ + value: project.projectKey, + label: project.displayName, + })), ]; return { projects, @@ -909,7 +922,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectByKey, projectDisplayNameByKey, projectGroupByScopeKey, - projectScopeItems: allProjectScopeItems, + projectScopeItems: lookupProjectScopeItems, } = useSidebarLookups(); const delegatedUnderParent = useDelegatedUnderParent(); const router = useRouter(); @@ -1015,12 +1028,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const [configuredDisabledEnvironmentIds, setConfiguredDisabledEnvironmentIds] = useState< ReadonlySet >(() => new Set()); - const connectedEnvironmentIds = new Set(environmentLabelById.keys()); + const catalogEnvironmentIds = new Set(environmentLabelById.keys()); // Derive the safe set during render so a catalog change can never paint a // filtered-empty frame before the stored selection is updated. const disabledEnvironmentIds = pruneDisabledEnvironmentIds({ disabledIds: configuredDisabledEnvironmentIds, - connectedEnvironmentIds, + connectedEnvironmentIds: catalogEnvironmentIds, }); const isEnvironmentFilterActive = disabledEnvironmentIds.size > 0; // An environment that leaves the catalog must not keep filtering from @@ -1040,7 +1053,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return duplicates; })(); - const projectScopeItems = allProjectScopeItems.filter( + const environmentProjectScopeItems = lookupProjectScopeItems.filter( (item) => item.value === "all" || projectGroupByScopeKey @@ -1068,7 +1081,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [searchHighlightKey, setSearchHighlightKey] = useState(null); - const searchEnvironmentIds = useConnectedEnvironmentIds(); + const connectedEnvironmentIds = useConnectedEnvironmentIds(); const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); const [renamingThreadRef, setRenamingThreadRef] = useState(null); @@ -1117,13 +1130,18 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }; // A scope whose project is gone reads as all projects once every snapshot has // loaded. The stored keys are left alone, so the scope returns with its project. + // An archived project's scope reads as all projects, matching the menu. + const scopeGroups = pickableProjectGroups(projectGroups); + const archivedProjectKeys = new Set( + projectGroups.filter((project) => project.archived).map((project) => project.projectKey), + ); const chosenProjectScopeKeys = resolveSidebarProjectScopeKeys({ groups: projectGroups, keys: persistedProjectScopeKeys, settings: projectGroupingSettings, canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, - }); - const chosenProjectGroups = projectGroups.filter((project) => + }).filter((key) => !archivedProjectKeys.has(key)); + const chosenProjectGroups = scopeGroups.filter((project) => chosenProjectScopeKeys.includes(project.projectKey), ); const chosenScopeProjectKeys = @@ -1197,6 +1215,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return environmentLabelById.get(thread.environmentId) ?? null; }; + // Pinning from a row keeps rows in place while the menu is open. + const projectScopeItems = useOrderWhileOpen( + environmentProjectScopeItems, + (item) => item.value, + projectScopeMenuState.open, + ); const selectedProjectScopeItem = projectScopeKeys.length > 1 ? null @@ -1252,7 +1276,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (disabledEnvironmentIds.size > 0) applyDisabledEnvironmentIds(new Set()); }); const threadSearch = useThreadSearch( - searchEnvironmentIds, + connectedEnvironmentIds, threadSearchQuery, scopedProjectGroups.length === 0 ? {} @@ -1300,6 +1324,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); }, ); + const { + canPinProjectGroup: canPinProject, + setProjectGroupPinned, + toggleProjectGroupPin, + } = useProjectPin(); const handleProjectSettings = useStableCallback( ( event: ReactMouseEvent | ReactKeyboardEvent, @@ -1326,6 +1355,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve [ { id: "new-thread", label: "New thread" }, { id: "copy-path", label: "Copy path" }, + ...(canPinProject(group) + ? [ + group.pinned + ? { id: "unpin", label: "Unpin project", icon: "pin-off" } + : { id: "pin", label: "Pin project", icon: "pin" }, + ] + : []), { id: "project-settings", label: "Project settings", icon: "settings" }, ], position, @@ -1339,6 +1375,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "copy-path": copyPathToClipboard(group.workspaceRoot, { path: group.workspaceRoot }); return; + case "pin": + case "unpin": + setProjectGroupPinned(group, clicked.value === "pin"); + return; case "project-settings": openProjectSettings(group); return; @@ -1388,16 +1428,19 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve scopedProjectKeys, disabledEnvironmentIds, serverConfigs, + connectedEnvironmentIds, optimisticDrop, threads, delegatedUnderParent, routeRowThreadKey, folderGroups: boringSidebarEnabled ? scopedProjectGroups.length === 0 - ? projectGroups + ? // An archived project keeps a folder for its active threads, below the rest. + [...scopeGroups, ...projectGroups.filter((project) => project.archived)] : scopedProjectGroups : null, - keepEmptyFolders: scopedProjectGroups.length > 0, + keepEmptyFolders: (group) => + scopedProjectGroups.length > 0 || (group.pinned && !group.archived), }); // Ranked across environments as the palette ranks them, so one @@ -1968,7 +2011,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); return { kind: "thread", key, section }; }); - // Boring mode keeps a project the user filtered to, so it can start a thread. + // Boring mode keeps a pinned open project or one the user filtered to, so it can start a thread. if ( !hasProjectFolders && pinnedThreads.length + @@ -2655,12 +2698,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve AuthOrchestrationOperateScope, ), branch: thread.branch ?? null, - projectFilter: threadProjectGroup - ? { - label: threadProjectGroup.displayName, - isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), - } - : null, + // An archived project is not a filter choice. + projectFilter: + threadProjectGroup && !threadProjectGroup.archived + ? { + label: threadProjectGroup.displayName, + isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), + } + : null, isPinned, isSettled, autoSettleEnabled: thread.autoSettleDisabledAt == null, @@ -2978,14 +3023,21 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const handleNewThreadClick = (event?: ReactMouseEvent) => { if ( boringSidebarEnabled || - shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length) + shouldCreateNewThreadInCurrentProject( + event?.shiftKey ?? false, + scopeGroups.length, + projectGroups.length, + ) ) { if (isMobile) setOpenMobile(false); + // With no current or open project, such as when every project is archived, pick one. void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, defaultProjectRef: newThreadContext.defaultProjectRef, handleNewThread, + }).then((started) => { + if (!started) openCommandPalette({ open: "new-thread-in" }); }); return; } @@ -2995,7 +3047,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + (shouldCreateNewThreadInCurrentProject(false, scopeGroups.length, projectGroups.length) + ? shortcutLabelForCommand(keybindings, "chat.newLocal") + : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3087,15 +3141,24 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if ( event.defaultPrevented || event.nativeEvent.isComposing || - event.keyCode === 229 || - event.ctrlKey || - event.altKey || - event.metaKey + event.keyCode === 229 ) { return; } const scopeKey = highlightedProjectScopeKeyRef.current; const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; + // The thread pin shortcut pins the highlighted project here. + if ( + project && + canPinProject(project) && + isProjectPinShortcut(event, keybindings) + ) { + event.preventDefault(); + event.stopPropagation(); + toggleProjectGroupPin(project); + return; + } + if (event.ctrlKey || event.altKey || event.metaKey) return; if (event.shiftKey && event.key === "Enter") { if (!project) return; event.preventDefault(); @@ -3151,6 +3214,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve machineByEnvironmentId={environmentMachineById} /> ) : null} + {project && canPinProject(project) ? ( + toggleProjectGroupPin(project)} + /> + ) : null} {project ? (
+ , + ), + ); + + await expect.element(screen.getByText("Review")).toBeVisible(); + const details = [ + ...inserted, + ...mutations.takeRecords().flatMap((record) => [...record.addedNodes]), + ].filter( + (node) => + node instanceof Element && + (node.matches('[aria-label="Show details"]') || + node.querySelector('[aria-label="Show details"]')), + ); + expect(details).toEqual([]); +}); + +it("shows the details button while text or nested text is cut, until it fits again", async () => { + await using screen = await render(noticeRow("4rem", "Waiting on a subagent with a long name")); + const details = screen.getByRole("button", { name: "Show details" }); + await expect.element(details).toBeInTheDocument(); + + await screen.rerender(noticeRow("4rem", "Ready")); + await expect.element(details).not.toBeInTheDocument(); + + await screen.rerender(noticeRow("4rem", "Waiting on a subagent with a long name")); + await expect.element(details).toBeInTheDocument(); + + screen.getByTestId("row").element().style.width = "40rem"; + await expect.element(details).not.toBeInTheDocument(); + + await screen.rerender( + noticeRow( + "40rem", + A long nested name, + ), + ); + await expect.element(details).toBeInTheDocument(); + + await screen.rerender( + + {noticeRow("4rem", "Waiting on a subagent with a long name")} + , + ); + await expect.element(details).toBeInTheDocument(); + await screen.rerender({noticeRow("4rem", "Ready")}); + await expect.element(details).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 755d20ba8c6ae..61778f817dc37 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -59,6 +59,8 @@ export function ComposerBannerStack({ )); } +const SUBPIXEL_ROUNDING = 0.001; + export function NoticeText({ children, className, @@ -70,27 +72,55 @@ export function NoticeText({ }) { const textRef = useRef(null); const detailsRef = useRef(null); - const [showDetails, setShowDetails] = useState(false); + const [detailsAtWidth, setDetailsAtWidth] = useState(null); + const showDetails = detailsAtWidth !== null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures text overflow in the DOM via ResizeObserver and MutationObserver to show the details button useLayoutEffect(() => { const text = textRef.current; if (!text) return; + const banner = text.closest("[data-slot=composer-banner]"); + const range = document.createRange(); const measure = () => { // Ignore the space taken by the details button itself so it cannot // sustain its own overflow after the text would otherwise fit. const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0; const hidden = getComputedStyle(text).position === "absolute"; - setShowDetails( + const overflowing = hidden || - [text, ...text.querySelectorAll("*")].some( - (element) => element.scrollWidth > element.clientWidth + recoveredWidth, - ), + [text, ...text.querySelectorAll("*")].some((element) => { + // A range spans laid-out content only; scrollWidth also counts positioned layers + // such as a link's touch hit area, which overflow without cutting any text. + // The two rects can disagree by float rounding (~0.00001px on phones); real + // overflow is at least one layout unit, 1/64 of a device pixel. + range.selectNodeContents(element); + return ( + range.getBoundingClientRect().width > + element.getBoundingClientRect().width + recoveredWidth + SUBPIXEL_ROUNDING + ); + }); + // The button's width can wrap the banner's actions onto a second row, which gives + // the text room and removes the button, which unwraps the row again. Once shown, + // the button stays until the banner itself gets wider than where it appeared. + const width = banner?.clientWidth ?? text.clientWidth; + setDetailsAtWidth((shownAt) => + overflowing + ? (shownAt ?? width) + : banner && shownAt !== null && width <= shownAt + ? shownAt + : null, ); }; measure(); - const stopObserving = observeResize(text, measure); - const mutations = new MutationObserver(measure); + const stopObserving = observeResize([text, banner ?? text], measure); + let content = text.textContent; + const mutations = new MutationObserver(() => { + if (text.textContent !== content) { + content = text.textContent; + setDetailsAtWidth(null); + } + measure(); + }); mutations.observe(text, { childList: true, subtree: true, characterData: true }); return () => { stopObserving(); @@ -179,7 +209,7 @@ function NoticeCard({ ) : null} {item.actions || item.onDismiss ? ( - + {item.actions} {item.onDismiss ? ( { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; + if (isInSideChatPane(target)) return; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } @@ -193,7 +195,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( className="px-4 pt-1.5" > - {activeQuestion.header} + + {activeQuestion.header} + {isCollapsed ? ( <> diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 965c95d481e99..a4a8993a7036e 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -371,7 +371,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ } >