diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 13ca38fed4ad6..78a0156d1eaa1 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -11,8 +11,8 @@ provided alongside this playbook. Everything machine-specific lives there, not h ## 1. Ask what went wrong -Your first message to the user: ask them to describe what went wrong, in their own -words. Ask them to paste screenshots directly into this session if they have any. +Unless the user already described it, your first message asks what went wrong, in +their own words. Ask them to paste screenshots directly into this session if they have any. Ask follow-up questions when the description is vague. Good repro steps are the most valuable thing you can extract from this conversation. diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 6b55644605891..70fe0a3593f85 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -5,12 +5,11 @@ on: branches: - main # Redeploys main after a variable or secret change. Alchemy redeploys the - # Worker when a value it reads changes, so force is rarely needed, and it - # replaces the Postgres runtime role (alchemy-run/alchemy#1832). + # Worker when a value it reads changes, so force is rarely needed. workflow_dispatch: inputs: force: - description: Redeploy every resource, including ones with no detected changes. Replaces the Postgres runtime role. + description: Redeploy every resource, including ones with no detected changes. type: boolean default: false diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 39db7e3236017..59a566dcdb955 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -275,6 +275,7 @@ jobs: --environment production \ --platform ${{ inputs.platform }} \ --message "$OTA_MESSAGE" \ + --emit-metadata \ --non-interactive # Reconcile each version separately because the major is in the fingerprint. @@ -313,8 +314,11 @@ jobs: --environment production \ --platform "$platform" \ --message "$message" \ + --emit-metadata \ --non-interactive; then echo ":white_check_mark: $platform/$profile: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" + # The next target's export replaces dist; keep this one for the map upload. + rm -rf "dist-$platform-$profile" && mv dist "dist-$platform-$profile" else failed=1 echo ":x: $platform/$profile: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" @@ -354,3 +358,18 @@ jobs: fi done exit "$failed" + + # Lets the relay symbolicate crash stacks from the updates published + # above. A step of its own so the R2 credentials reach only this script, + # not `eas update` and the dependencies it runs. Never fails the job. + - name: Upload OTA source maps + if: ${{ !cancelled() && steps.expo-token.outputs.present == 'true' }} + working-directory: apps/mobile + env: + MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }} + run: | + for dir in dist dist-*; do + if [ -f "$dir/eas-update-metadata.json" ]; then + node scripts/upload-sourcemaps.mts ota "$dir" + fi + done diff --git a/AGENTS.md b/AGENTS.md index 57bd57463790f..34711c2bc2f79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,7 +200,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. - Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. -- React effects are for synchronizing with something outside React. Derive in render and act in handlers instead of syncing state, but don't hide real lifecycle work in refs or stores to dodge one. The `t3code/react-effect-budget` lint rule holds each file to its count in `scripts/effect-budget.json`; a new effect states its reason with `// oxlint-disable-next-line t3code/react-effect-budget -- `. +- React effects are for synchronizing with something outside React. Derive in render and act in handlers instead of syncing state, but don't hide real lifecycle work in refs or stores to dodge one. The `t3code/no-unreasoned-state-sync` lint rule reports every effect; one that stays states its reason with `// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- `. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index a244ff4b3fa7a..34b7286c335ed 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -156,6 +156,7 @@ describe("DesktopClerk", () => { `setPath:userData:${userData}`, "requestSingleInstanceLock", "on:second-instance", + "on:open-url", "createClerkBridge", ]); }, @@ -200,7 +201,7 @@ describe("DesktopClerk", () => { }, ); - it.effect("delivers queued startup links and later links once in arrival order", () => { + it.effect("delivers each queued startup link once, before later links", () => { storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); const listeners = new Map void>(); @@ -208,6 +209,7 @@ describe("DesktopClerk", () => { const first = "t3code://new?prompt=first"; const second = "t3code://threads/env-1/thread-1"; const later = "t3code://new?prompt=later"; + const launchPair = "t3code://pair?pairingUrl=https%3A%2F%2Fvps.example.com%2F%23token%3Dabc"; const delivered = Promise.withResolvers(); const electronApp = { on: (name: string, listener: (...args: unknown[]) => void) => @@ -220,13 +222,14 @@ describe("DesktopClerk", () => { const clerk = yield* DesktopClerk.DesktopClerk; listeners.get("second-instance")?.({}, ["t3", first]); listeners.get("second-instance")?.({}, ["t3", second]); + listeners.get("open-url")?.({ preventDefault: () => undefined }, launchPair); assert.deepEqual(received, []); yield* clerk.configure; listeners.get("second-instance")!({}, ["t3", later]); yield* Effect.promise(() => delivered.promise); - assert.deepEqual(received, [first, second, later]); + assert.deepEqual(received, [launchPair, first, second, later]); }).pipe( Effect.scoped, Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), @@ -425,6 +428,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" }), } as unknown as ElectronApp.ElectronApp["Service"]; const received: Array = []; + const pairLink = "t3code://pair?pairingUrl=https%3A%2F%2Fvps.example.com%2F%23token%3Dabc"; yield* Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; yield* clerk.configure; @@ -437,6 +441,8 @@ it.effect("hands web and app links to the renderer and leaves other links alone" assert.strictEqual(open("http://localhost:3000/"), 1); assert.strictEqual(open("t3code://new?prompt=hi"), 1); assert.strictEqual(open("t3code://threads/env-1/thread-1"), 1); + assert.strictEqual(open(pairLink), 1); + assert.strictEqual(open("t3code://pair"), 0); assert.strictEqual(open("mailto:hello@example.com"), 0); assert.strictEqual(open("t3code://somewhere-else"), 0); yield* Effect.yieldNow; @@ -445,6 +451,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" "http://localhost:3000/", "t3code://new?prompt=hi", "t3code://threads/env-1/thread-1", + pairLink, ]); }).pipe( Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 3e0d753d1bfba..992e1fe056937 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -13,6 +13,7 @@ import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCal import * as ElectronShell from "../electron/ElectronShell.ts"; import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; +import { readPairingAppLink } from "@t3tools/shared/remote"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -118,6 +119,15 @@ export const make = Effect.gen(function* () { yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { handleSecondInstance(argv); }); + // macOS hands a launch link to open-url, which can fire before configure runs. + const pendingOpenUrls: Array = []; + let handleOpenUrl = (url: string) => { + pendingOpenUrls.push(url); + return true; + }; + yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { + if (handleOpenUrl(url)) event.preventDefault(); + }); yield* Effect.acquireRelease( Effect.try({ @@ -194,7 +204,8 @@ export const make = Effect.gen(function* () { return true; }; const openAppLink = (value: string | undefined) => { - if (!value || promptLinkAppPath(value) === null) return false; + if (!value || (promptLinkAppPath(value) === null && readPairingAppLink(value) === null)) + return false; void runPromise(webLinks.receive(value)); return true; }; @@ -205,15 +216,12 @@ export const make = Effect.gen(function* () { void runPromise(webLinks.receive(url)); return true; }; - yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { - if ( - startProviderAuthHandoff(url) || - resumeProviderAuth(url) || - openWebLink(url) || - openAppLink(url) - ) - event.preventDefault(); - }); + handleOpenUrl = (url) => + startProviderAuthHandoff(url) || + resumeProviderAuth(url) || + openWebLink(url) || + openAppLink(url); + for (const url of pendingOpenUrls.splice(0)) handleOpenUrl(url); yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => { if (!DesktopWebLinks.isWebPageFile(path)) return; event.preventDefault(); diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index d9b797dd0451f..22af4a829e049 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -168,12 +168,7 @@ describe("BrowserSession", () => { return granted; }; - for (const permission of [ - "clipboard-read", - "clipboard-sanitized-write", - "geolocation", - "fullscreen", - ]) { + for (const permission of ["clipboard-sanitized-write", "geolocation", "fullscreen"]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); assert.isTrue( checkHandler(null, permission) as boolean, @@ -226,6 +221,55 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("asks once per origin before a page reads the clipboard", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0]; + const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + assert.isFunction(checkHandler); + + const readClipboard = (requestingUrl: string) => + Effect.callback((resume) => { + requestHandler( + null, + "clipboard-read", + (granted: boolean) => resume(Effect.succeed(granted)), + { requestingUrl, isMainFrame: true }, + ); + }); + const checkClipboard = (origin: string): boolean => + checkHandler(null, "clipboard-read", origin, {}) as boolean; + + showMessageBox.mockReset(); + showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1); + + assert.isFalse(checkClipboard("https://allowed.test")); + assert.isTrue(yield* readClipboard("https://allowed.test/page")); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + assert.strictEqual( + showMessageBox.mock.calls[0]?.[0].message, + "https://allowed.test wants to see text and images copied to the clipboard", + ); + assert.deepStrictEqual(showMessageBox.mock.calls[0]?.[0].buttons, ["Allow", "Block"]); + // Enter must not accept a prompt the page raised while the user was typing. + assert.strictEqual(showMessageBox.mock.calls[0]?.[0].defaultId, 1); + + assert.isTrue(yield* readClipboard("https://allowed.test/other")); + assert.isTrue(checkClipboard("https://allowed.test")); + + assert.isFalse(yield* readClipboard("https://blocked.test/")); + assert.isFalse(yield* readClipboard("https://blocked.test/again")); + assert.isFalse(checkClipboard("https://blocked.test")); + assert.isTrue(checkClipboard("https://allowed.test")); + assert.strictEqual(showMessageBox.mock.calls.length, 2); + }).pipe(Effect.provide(layer)), + ); + it.effect("clears a partition whose session has not been opened yet", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 0f900d521b739..2ea7efdb88ee0 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -20,7 +20,6 @@ export type BrowserSessionPartitionNamespace = "profile"; // Electron's clipboard API uses clipboard-sanitized-write and checks both permission handlers. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ - "clipboard-read", "clipboard-sanitized-write", "geolocation", // DesktopWindow confines HTML fullscreen to the guest's webview. @@ -55,6 +54,19 @@ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => const MAX_PROMPT_URL_LENGTH = 300; +const truncateForPrompt = (text: string): string => + text.length > MAX_PROMPT_URL_LENGTH ? `${text.slice(0, MAX_PROMPT_URL_LENGTH)}…` : text; + +const permissionOrigin = (rawUrl: string | undefined): string | null => { + if (!rawUrl) return null; + try { + const { origin } = new URL(rawUrl); + return origin === "null" ? null : origin; + } catch { + return null; + } +}; + export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()( "BrowserSessionPartitionDerivationError", { @@ -175,47 +187,70 @@ export const make = Effect.gen(function* BrowserSessionMake() { const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. - let externalPromptOpen = false; - /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ - const confirmOpenExternal = ( + let permissionPromptOpen = false; + /** Settles with `undefined` when the user gave no answer: another prompt was open or the dialog failed. */ + const promptPermission = ( requester: WebContents | null, - rawUrl: string | undefined, - callback: (granted: boolean) => void, + prompt: { + readonly buttons: readonly [allow: string, deny: string]; + readonly message: string; + readonly detail?: string; + }, + callback: (answer: boolean | undefined) => void, ): void => { - const url = externalProtocolPromptUrl(rawUrl); - if (url === null || externalPromptOpen) { - callback(false); + if (permissionPromptOpen) { + callback(undefined); return; } - externalPromptOpen = true; - const settle = (granted: boolean) => + permissionPromptOpen = true; + const settle = (answer: boolean | undefined) => Effect.sync(() => { - externalPromptOpen = false; - callback(granted); + permissionPromptOpen = false; + callback(answer); }); - const shownUrl = - url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url; runFork( electronDialog .showMessageBox( { type: "question", - buttons: ["Open", "Cancel"], - defaultId: 0, + buttons: [...prompt.buttons], + // Default to deny so an Enter typed into the page cannot accept a prompt it raised. + defaultId: 1, cancelId: 1, - message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, - detail: `A page in the browser wants to open another application:\n\n${shownUrl}`, + message: prompt.message, + ...(prompt.detail === undefined ? {} : { detail: prompt.detail }), }, previewHostWindow(requester), ) .pipe( - Effect.map(({ response }) => response === 0), - Effect.orElseSucceed(() => false), + Effect.map(({ response }): boolean | undefined => response === 0), + Effect.orElseSucceed(() => undefined), Effect.flatMap(settle), - Effect.onInterrupt(() => settle(false)), + Effect.onInterrupt(() => settle(undefined)), ), ); }; + /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ + const confirmOpenExternal = ( + requester: WebContents | null, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const url = externalProtocolPromptUrl(rawUrl); + if (url === null) { + callback(false); + return; + } + promptPermission( + requester, + { + buttons: ["Open", "Cancel"], + message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, + detail: `A page in the browser wants to open another application:\n\n${truncateForPrompt(url)}`, + }, + (answer) => callback(answer === true), + ); + }; const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( @@ -248,6 +283,35 @@ export const make = Effect.gen(function* BrowserSessionMake() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); + // Like Chrome, clipboard reads ask once per origin; both answers last for the session. + const clipboardReadDecisions = new Map(); + const confirmClipboardRead = ( + requester: WebContents, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const origin = permissionOrigin(rawUrl); + if (origin === null) { + callback(false); + return; + } + const remembered = clipboardReadDecisions.get(origin); + if (remembered !== undefined) { + callback(remembered); + return; + } + promptPermission( + requester, + { + buttons: ["Allow", "Block"], + message: `${truncateForPrompt(origin)} wants to see text and images copied to the clipboard`, + }, + (answer) => { + if (answer !== undefined) clipboardReadDecisions.set(origin, answer); + callback(answer === true); + }, + ); + }; // Rewriting Electron's native User-Agent makes Turnstile fail integrity checks with error 600010. browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { @@ -259,11 +323,21 @@ export const make = Effect.gen(function* BrowserSessionMake() { ); return; } + if (permission === "clipboard-read") { + confirmClipboardRead( + webContents, + "requestingUrl" in details ? details.requestingUrl : undefined, + callback, + ); + return; + } callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); }, ); - browserSession.setPermissionCheckHandler((_webContents, permission) => - ALLOWED_PREVIEW_PERMISSIONS.has(permission), + browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => + permission === "clipboard-read" + ? clipboardReadDecisions.get(permissionOrigin(requestingOrigin) ?? "") === true + : ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); const next = new Map(sessions); next.set(partition, browserSession); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 92e587f171c7b..980da9c83c33a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -17,6 +17,12 @@ const runtimeVersionPolicy = const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; +// Compiled into the native project. Without a relay, Observe's native side could dispatch to +// Expo's own ingest before observe.ts turns it off; `.invalid` never resolves (RFC 6761). +const observeEndpointUrl = repoEnv.T3CODE_RELAY_URL?.trim() + ? new URL("/v1/mobile/observe", repoEnv.T3CODE_RELAY_URL.trim()).toString() + : "https://observe.invalid"; + const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; const androidAdaptiveForeground = "./assets/android-icon-foreground.png"; @@ -450,6 +456,7 @@ const config: ExpoConfig = { }, eas: { projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", + observe: { endpointUrl: observeEndpointUrl }, }, }, owner: "pingdotgg", diff --git a/apps/mobile/assets/context-chip-icons/server.png b/apps/mobile/assets/context-chip-icons/server.png new file mode 100644 index 0000000000000..92bc2ce457f16 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/server.png differ diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md index 46b0539e5c97b..939ac2d9147ed 100644 --- a/apps/mobile/deps/README.md +++ b/apps/mobile/deps/README.md @@ -1,3 +1,3 @@ -The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries native streaming memory bounds (an 8 MiB measurement-key budget and coalesced pending renders), plus changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), and development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)). +The Enriched Markdown tarball comes from Julius's [`fork/t3-mobile` branch](https://github.com/pingdotgg/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. React block renderers and signed image sources (`renderers`, `resolveImageSource`) are upstream. The fork carries native streaming memory bounds (an 8 MiB measurement-key budget and coalesced pending renders), plus changes with an open upstream PR: the `onLinksChange` link list that link pills and iOS link menus read ([#1088](https://github.com/software-mansion/enriched-markdown/pull/1088)), Android inline image alignment ([#1089](https://github.com/software-mansion/enriched-markdown/pull/1089)), clipboard link metadata ([#962](https://github.com/software-mansion/enriched-markdown/pull/962)), development-host autolinks ([#961](https://github.com/software-mansion/enriched-markdown/pull/961)), and `md4cFlags.preserveWindowsPaths`, which keeps backslashes in Windows drive paths in prose ([#1105](https://github.com/software-mansion/enriched-markdown/pull/1105), after [md4c#5](https://github.com/software-mansion-labs/md4c/pull/5)). To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Keep the package version aligned with Julius's `fork/t3-mobile` branch. Rename the archive to include the first eight characters of the source commit, such as `react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz`, so each build has a distinct dependency path without version bumps. Update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes. diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz new file mode 100644 index 0000000000000..06ac560ff2eba Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz differ diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz deleted file mode 100644 index 3cf77c4adf971..0000000000000 Binary files a/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz deleted file mode 100644 index 2e076b177f3f8..0000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz new file mode 100644 index 0000000000000..f1eb3858e9865 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.48.tgz differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index f694fc8d6ef26..f773c2c5bbc3a 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -66,7 +66,8 @@ "corepack": true, "env": { "APP_VARIANT": "production", - "NODE_OPTIONS": "--max-old-space-size=4096" + "NODE_OPTIONS": "--max-old-space-size=4096", + "SOURCEMAP_FILE": "/tmp/t3code-ios-main.jsbundle.map" }, "channel": "production", "environment": "production", diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 6aac4869d9717..169d5cc4048ba 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,5 +1,7 @@ // Must stay first: the render counter wraps the DevTools hook before any React commit. import "./src/features/development/render-counter-entry"; +// Next, so errors thrown while the rest of the app loads are recorded too. +import "./src/features/observability/observe"; import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; import { LogBox } from "react-native"; diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index d1edfecb3e595..32969b1b71537 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -284,6 +284,7 @@ class T3WorkspaceNavigationView( }, ) { val fraction = progress.coerceIn(0f, 1f) + val railShape = RoundedCornerShape(topEnd = 16.dp * fraction, bottomEnd = 16.dp * fraction) if (fraction > 0f) { Scrim( contentDescription = "Close navigation rail", @@ -295,8 +296,8 @@ class T3WorkspaceNavigationView( Modifier .fillMaxHeight() .width(lerp(RAIL_WIDTH, EXPANDED_RAIL_WIDTH, progress.coerceAtLeast(0f))) - .shadow(RAIL_SHADOW * fraction, RAIL_EXPANDED_SHAPE, clip = false) - .background(Color(containerColor), RAIL_EXPANDED_SHAPE) + .shadow(RAIL_SHADOW * fraction, railShape, clip = false) + .background(Color(containerColor), railShape) .padding(top = safeAreaTop.dp, bottom = safeAreaBottom.dp + 12.dp) .selectableGroup(), ) { @@ -437,7 +438,6 @@ class T3WorkspaceNavigationView( val FAB_SIZE = 48.dp val ICON_SIZE = 24.dp val RAIL_SHADOW = 6.dp - val RAIL_EXPANDED_SHAPE = RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp) const val DISABLED_ALPHA = 0.4f // Labels fade in over the second half of the widening, once there is room for them, and 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 8426ddb565a40..8cb7ad17e0d0b 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 @@ -1423,13 +1423,24 @@ private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f } +// A single-line preview never shows this many characters, so longer text is cut before measuring. +private const val ELLIPSIZE_MAX_CHARS = 500 + private fun ellipsize(value: String, paint: Paint, width: Float): String { - if (paint.measureText(value) <= width) return value + val truncated = value.length > ELLIPSIZE_MAX_CHARS + val capEnd = surrogateSafeEnd(value, ELLIPSIZE_MAX_CHARS) + val text = if (truncated) value.substring(0, capEnd) else value + if (!truncated && paint.measureText(text) <= width) return value val suffix = "..." val available = max(0f, width - paint.measureText(suffix)) - var end = value.length - while (end > 0 && paint.measureText(value, 0, end) > available) end -= 1 - return value.substring(0, end) + suffix + val end = surrogateSafeEnd(text, paint.breakText(text, true, available, null)) + return text.substring(0, end) + suffix +} + +private fun surrogateSafeEnd(value: String, end: Int): Int { + val splitsPair = end in 1 until value.length && + Character.isSurrogatePair(value[end - 1], value[end]) + return if (splitsPair) end - 1 else end } private fun indexFileHeaders(rows: List): IntArray { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e91ec70d0ba1d..f5c1c6521812e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -46,6 +46,7 @@ "measure-renders": "node scripts/measure-renders.mts", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", "generate:highlights": "node scripts/generate-highlights.mts", + "eas-build-on-success": "node scripts/upload-sourcemaps.mts build", "test": "vp test run --project unit --project react", "typecheck": "tsc --noEmit" }, @@ -103,6 +104,7 @@ "expo-linking": "~58.0.13", "expo-network": "~58.0.3", "expo-notifications": "~58.1.5", + "expo-observe": "~58.0.15", "expo-paste-input": "^0.1.15", "expo-quick-actions": "^6.0.2", "expo-secure-store": "~58.0.2", @@ -119,7 +121,7 @@ "react-dom": "catalog:", "react-native": "0.88.0", "react-native-blob-util": "0.25.1", - "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-558767d9.tgz", + "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.16-29ddd424.tgz", "react-native-gesture-handler": "~3.3.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.22.8", @@ -127,7 +129,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.45.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.48.tgz", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", "react-native-worklets": "0.13.0", @@ -145,6 +147,7 @@ "@types/mdast": "^4.0.4", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "aws4fetch": "1.0.20", "babel-preset-expo": "~58.0.17", "jsdom": "^30.0.1", "tailwindcss": "^4.0.0", diff --git a/apps/mobile/scripts/sourcemap-upload.mts b/apps/mobile/scripts/sourcemap-upload.mts new file mode 100644 index 0000000000000..a962f98a37805 --- /dev/null +++ b/apps/mobile/scripts/sourcemap-upload.mts @@ -0,0 +1,165 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { mobileSourceMapKey, type MobilePlatform } from "@t3tools/shared/mobileSourceMaps"; +import { AwsClient } from "aws4fetch"; + +export interface SourceMapUpload { + readonly key: string; + readonly path: string; +} + +/** The `MOBILE_SOURCEMAPS_R2` secret: the relay stack's `mobileSourceMapsUploadCredentials`. */ +interface UploadCredentials { + readonly endpoint: string; + readonly bucketName: string; + readonly accessKeyId: string; + readonly secretAccessKey: string; +} + +const isPlatform = (value: unknown): value is MobilePlatform => + value === "ios" || value === "android"; + +const readJson = (path: string): unknown => JSON.parse(NodeFS.readFileSync(path, "utf8")); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export function parseCredentials(value: string | undefined): UploadCredentials | undefined { + if (!value?.trim()) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + // JSON.parse's message quotes the input, which here is the secret. + throw new Error("MOBILE_SOURCEMAPS_R2 is not valid JSON."); + } + if (!isRecord(parsed)) throw new Error("MOBILE_SOURCEMAPS_R2 is not a JSON object."); + const { endpoint, bucketName, accessKeyId, secretAccessKey } = parsed; + if ( + typeof endpoint !== "string" || + typeof bucketName !== "string" || + typeof accessKeyId !== "string" || + typeof secretAccessKey !== "string" + ) { + throw new Error( + "MOBILE_SOURCEMAPS_R2 needs endpoint, bucketName, accessKeyId and secretAccessKey.", + ); + } + return { endpoint, bucketName, accessKeyId, secretAccessKey }; +} + +/** One map per update `eas update --emit-metadata` published, next to the bundle `metadata.json` names. */ +export function otaSourceMapUploads(distDir: string): ReadonlyArray { + const metadata = readJson(NodePath.join(distDir, "metadata.json")); + const easMetadata = readJson(NodePath.join(distDir, "eas-update-metadata.json")); + const fileMetadata = isRecord(metadata) ? metadata.fileMetadata : undefined; + const updates = isRecord(easMetadata) ? easMetadata.updates : undefined; + if (!isRecord(fileMetadata) || !Array.isArray(updates)) { + throw new Error(`${distDir} is missing Expo export or EAS update metadata.`); + } + return updates.flatMap((update): Array => { + if (!isRecord(update) || !isPlatform(update.platform) || typeof update.id !== "string") { + return []; + } + const platformFiles = fileMetadata[update.platform]; + const bundle = isRecord(platformFiles) ? platformFiles.bundle : undefined; + if (typeof bundle !== "string") return []; + return [ + { + key: mobileSourceMapKey(update.platform, "ota", update.id), + path: NodePath.join(distDir, `${bundle}.map`), + }, + ]; + }); +} + +/** Android's Gradle plugin writes the map under the release variant; iOS to `SOURCEMAP_FILE` (eas.json). */ +export function buildSourceMapUpload( + env: Readonly>, + projectDir: string, +): SourceMapUpload { + const platform = env.EAS_BUILD_PLATFORM; + const buildId = env.EAS_BUILD_ID; + if (!isPlatform(platform) || !buildId) { + throw new Error("EAS_BUILD_PLATFORM and EAS_BUILD_ID are only set on EAS Build."); + } + if (platform === "ios") { + if (!env.SOURCEMAP_FILE) throw new Error("SOURCEMAP_FILE is not set for this iOS build."); + return { key: mobileSourceMapKey("ios", "build", buildId), path: env.SOURCEMAP_FILE }; + } + const variantsDir = NodePath.join(projectDir, "android/app/build/generated/sourcemaps/react"); + const variant = NodeFS.existsSync(variantsDir) + ? NodeFS.readdirSync(variantsDir).find((name) => /release$/iu.test(name)) + : undefined; + if (!variant) throw new Error(`No release source map under ${variantsDir}.`); + return { + key: mobileSourceMapKey("android", "build", buildId), + path: NodePath.join(variantsDir, variant, "index.android.bundle.map"), + }; +} + +/** + * EAS writes the app's debug symbols next to the `.ipa`, so native frames in iOS crash + * reports can be read with `atos`. Undefined when the build left none. + */ +export function buildDsymUpload( + env: Readonly>, + projectDir: string, +): SourceMapUpload | undefined { + if (env.EAS_BUILD_PLATFORM !== "ios" || !env.EAS_BUILD_ID) return undefined; + const buildDir = NodePath.join(projectDir, "ios/build"); + const dsym = NodeFS.existsSync(buildDir) + ? NodeFS.readdirSync(buildDir).find((name) => name.endsWith(".app.dSYM.zip")) + : undefined; + if (!dsym) return undefined; + return { key: `ios/dsym/${env.EAS_BUILD_ID}.zip`, path: NodePath.join(buildDir, dsym) }; +} + +/** Only what the relay reads: source text stays out of the bucket, and the rest is dead weight. */ +function stripToMappings(text: string): string { + const map: unknown = JSON.parse(text); + if (!isRecord(map)) throw new Error("Source map is not a JSON object."); + const { version, sources, sourceRoot, mappings } = map; + return JSON.stringify({ version, sources, sourceRoot, mappings }); +} + +export function uploadSourceMap( + credentials: UploadCredentials, + upload: SourceMapUpload, +): Promise { + return putObject( + credentials, + upload.key, + stripToMappings(NodeFS.readFileSync(upload.path, "utf8")), + "application/json", + ); +} + +export function uploadDsym(credentials: UploadCredentials, upload: SourceMapUpload) { + const body = new Uint8Array(NodeFS.readFileSync(upload.path)); + return putObject(credentials, upload.key, body, "application/zip"); +} + +async function putObject( + credentials: UploadCredentials, + key: string, + body: string | Uint8Array, + contentType: string, +): Promise { + const client = new AwsClient({ + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + service: "s3", + region: "auto", + }); + const url = `${credentials.endpoint.replace(/\/$/u, "")}/${credentials.bucketName}/${key}`; + const response = await client.fetch(url, { + method: "PUT", + body, + headers: { "content-type": contentType }, + }); + if (!response.ok) { + throw new Error(`R2 answered ${response.status}: ${(await response.text()).slice(0, 500)}`); + } + return body.length; +} diff --git a/apps/mobile/scripts/sourcemap-upload.test.ts b/apps/mobile/scripts/sourcemap-upload.test.ts new file mode 100644 index 0000000000000..ee8ae57d690ae --- /dev/null +++ b/apps/mobile/scripts/sourcemap-upload.test.ts @@ -0,0 +1,136 @@ +import * as NodeFS from "node:fs"; +import * as NodeHttp from "node:http"; +import type * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + buildDsymUpload, + buildSourceMapUpload, + otaSourceMapUploads, + uploadSourceMap, +} from "./sourcemap-upload.mts"; + +const directories: Array = []; +const tempDir = () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "sourcemap-upload-")); + directories.push(dir); + return dir; +}; +const writeFile = (path: string, contents: string) => { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, contents); +}; + +afterEach(() => { + for (const dir of directories.splice(0)) NodeFS.rmSync(dir, { recursive: true, force: true }); +}); + +describe("uploadSourceMap", () => { + it("PUTs each published update's map, only what the relay reads, under its update id", async () => { + const dist = tempDir(); + writeFile( + NodePath.join(dist, "metadata.json"), + JSON.stringify({ + fileMetadata: { + android: { bundle: "_expo/static/js/android/index-abc.hbc" }, + ios: { bundle: "_expo/static/js/ios/index-def.hbc" }, + }, + }), + ); + writeFile( + NodePath.join(dist, "eas-update-metadata.json"), + JSON.stringify({ + updates: [ + { id: "update-android", platform: "android" }, + { id: "update-ios", platform: "ios" }, + { id: "update-web", platform: "web" }, + ], + }), + ); + for (const platform of ["android", "ios"]) { + writeFile( + NodePath.join( + dist, + `_expo/static/js/${platform}/index-${platform === "ios" ? "def" : "abc"}.hbc.map`, + ), + JSON.stringify({ + version: 3, + sources: [`${platform}.ts`], + sourcesContent: ["secret"], + names: ["render"], + x_facebook_sources: [[null]], + mappings: "", + }), + ); + } + + const requests: Array<{ method?: string; url?: string; auth?: string; body: string }> = []; + const server = NodeHttp.createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => { + requests.push({ + method: request.method, + url: request.url, + auth: request.headers.authorization, + body, + }); + response.end(); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address() as NodeNet.AddressInfo; + const credentials = { + endpoint: `http://127.0.0.1:${address.port}`, + bucketName: "maps", + accessKeyId: "key-id", + secretAccessKey: "secret-key", + }; + for (const upload of otaSourceMapUploads(dist)) { + await uploadSourceMap(credentials, upload); + } + expect(requests).toEqual( + ["android", "ios"].map((platform) => ({ + method: "PUT", + url: `/maps/${platform}/ota/update-${platform}.map`, + auth: expect.stringMatching(/^AWS4-HMAC-SHA256 Credential=key-id\/\d{8}\/auto\/s3\//), + body: JSON.stringify({ version: 3, sources: [`${platform}.ts`], mappings: "" }), + })), + ); + } finally { + server.close(); + } + }); +}); + +describe("buildSourceMapUpload", () => { + it("names the EAS build's embedded bundle map under its build id", () => { + const project = tempDir(); + const androidMap = NodePath.join( + project, + "android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", + ); + writeFile(androidMap, "{}"); + expect( + buildSourceMapUpload({ EAS_BUILD_PLATFORM: "android", EAS_BUILD_ID: "build-1" }, project), + ).toEqual({ key: "android/build/build-1.map", path: androidMap }); + expect( + buildSourceMapUpload( + { EAS_BUILD_PLATFORM: "ios", EAS_BUILD_ID: "build-2", SOURCEMAP_FILE: "/tmp/main.map" }, + project, + ), + ).toEqual({ key: "ios/build/build-2.map", path: "/tmp/main.map" }); + }); + + it("names an iOS build's dSYMs under its build id", () => { + const project = tempDir(); + const dsym = NodePath.join(project, "ios/build/T3Code.app.dSYM.zip"); + writeFile(dsym, "zip"); + expect( + buildDsymUpload({ EAS_BUILD_PLATFORM: "ios", EAS_BUILD_ID: "build-3" }, project), + ).toEqual({ key: "ios/dsym/build-3.zip", path: dsym }); + }); +}); diff --git a/apps/mobile/scripts/sync-context-chip-icons.mjs b/apps/mobile/scripts/sync-context-chip-icons.mjs index aac4983d75e2c..d99d4d3c98478 100644 --- a/apps/mobile/scripts/sync-context-chip-icons.mjs +++ b/apps/mobile/scripts/sync-context-chip-icons.mjs @@ -43,6 +43,7 @@ const lucideIcons = [ "message-circle", "messages-square", "mouse-pointer-click", + "server", "terminal", ]; diff --git a/apps/mobile/scripts/upload-sourcemaps.mts b/apps/mobile/scripts/upload-sourcemaps.mts new file mode 100644 index 0000000000000..1656035906e44 --- /dev/null +++ b/apps/mobile/scripts/upload-sourcemaps.mts @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Usage: `ota ` after `eas update --emit-metadata`, or `build` from eas-build-on-success. +// Problems are warnings, never failures: an unreadable stack is not worth failing a release over. +import { + buildDsymUpload, + buildSourceMapUpload, + otaSourceMapUploads, + parseCredentials, + uploadDsym, + uploadSourceMap, + type SourceMapUpload, +} from "./sourcemap-upload.mts"; + +const warn = (message: string) => { + // GitHub Actions shows this as an annotation; elsewhere it is a log line. + console.warn(process.env.GITHUB_ACTIONS ? `::warning::${message}` : `warning: ${message}`); +}; + +const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +async function main() { + const [mode, distDir] = process.argv.slice(2); + const credentials = parseCredentials(process.env.MOBILE_SOURCEMAPS_R2); + if (!credentials) { + warn("MOBILE_SOURCEMAPS_R2 is not set; crash stacks from this bundle stay minified."); + return; + } + let uploads: ReadonlyArray; + if (mode === "ota" && distDir) { + uploads = otaSourceMapUploads(distDir); + } else if (mode === "build") { + uploads = [buildSourceMapUpload(process.env, process.cwd())]; + } else { + throw new Error("Usage: upload-sourcemaps.mts ota | build"); + } + if (uploads.length === 0) warn("Found no source maps to upload."); + for (const upload of uploads) { + try { + const bytes = await uploadSourceMap(credentials, upload); + console.log(`Uploaded ${upload.key} (${(bytes / 1e6).toFixed(1)} MB)`); + } catch (error) { + warn(`Could not upload ${upload.key}: ${errorMessage(error)}`); + } + } + if (mode !== "build" || process.env.EAS_BUILD_PLATFORM !== "ios") return; + const dsym = buildDsymUpload(process.env, process.cwd()); + if (!dsym) { + warn("Found no dSYM under ios/build; native iOS frames from this build stay unreadable."); + return; + } + try { + const bytes = await uploadDsym(credentials, dsym); + console.log(`Uploaded ${dsym.key} (${(bytes / 1e6).toFixed(1)} MB)`); + } catch (error) { + warn(`Could not upload ${dsym.key}: ${errorMessage(error)}`); + } +} + +await main().catch((error: unknown) => { + warn(`Source map upload skipped: ${errorMessage(error)}`); +}); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 598fe99deac4f..5798c1adb060d 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -52,7 +52,7 @@ import { OverlayPortalHost } from "./components/ui/OverlayPortal"; import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; import { hasCloudPublicConfig } from "./features/cloud/publicConfig"; import { takeScheduledTaskNewLink } from "./features/settings/scheduled-task-editor-state"; -import { startDevAutoConnect } from "./features/connection/devAutoConnect"; +import { pairingFormLinkAction, startDevAutoConnect } from "./features/connection/devAutoConnect"; import { resolvePendingNewTaskPromptLink, takeNewTaskPromptLink, @@ -129,6 +129,10 @@ const appLinking = { ): ReturnType => // Our router handles the selection; React Navigation's type only lists its own actions. (workspaceDetailLinkAction(state) as ReturnType) ?? + pairingFormLinkAction( + state, + rootNavigationRef.isReady() ? rootNavigationRef.getRootState() : undefined, + ) ?? getActionFromState(state, options), getStateFromPath: getAppStateFromPath, }; @@ -191,6 +195,7 @@ disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.ho function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hides the native splash screen once appearance preferences have loaded useEffect(() => { if (isReady) void SplashScreen.hide(); }, [isReady]); diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 20c7485ef15d6..8121bfdece763 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -324,6 +324,9 @@ export function ComposerContextSheet(props: { ) : null} ) : null} + {record.kind === "environment" ? ( + + ) : null} {record.kind === "skill" ? ( diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 0098605414bd2..c96e872fd6938 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -61,6 +61,7 @@ export function ComposerEditor({ ? preferencesResult.value.composerEnterBehavior : undefined; const contextHistory = createComposerDraftContextHistory(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases the attachment file leases held for native text undo when the history is replaced or unmounts useEffect(() => () => contextHistory.dispose(), [contextHistory]); const changeText = (text: string) => { const restored = contextHistory.restore( diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index ed5c6e56ee891..45f09779f13be 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -34,6 +34,7 @@ function NativeFilePreview(props: { else Dialog.alert("Could not open preview", "The file could not be loaded. Please try again."); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- presents the native file preview and dismisses it on cleanup useEffect(() => { let canceled = false; const presentation = diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index b0cf1fc5c3ab0..486cd9e236b8e 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -55,6 +55,7 @@ function DocumentPreview(props: { "A compatible viewer must be installed. Check your connection and try again.", ); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the attachment in an external viewer and aborts the open on cleanup useEffect(() => { const controller = new AbortController(); const input = { diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 8079379fd4895..849bacd8ac41b 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -86,6 +86,7 @@ function ResolvedFilePreview(props: { props.onRequestClose(); }); // Resolves once per presentation and holds local file leases until it closes. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resolves preview files and holds their local file leases until the preview closes useEffect(() => { if (items.every((item) => "uri" in item)) return; const controller = new AbortController(); diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index ea39cac15baff..12d31986710cd 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -33,6 +33,7 @@ function LoadedMediaVideo(props: { useMediaVideoPlayback(player, props, playback); const previousPlaybackRequested = useRef(false); const onPlaybackStart = useEffectEvent(() => props.onPlaybackStart?.()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to native video player events to keep the shared audio session in step useEffect(() => { const subscription = player.addListener("playingChange", ({ isPlaying }) => { if (isPlaying && !active.current && !fullscreen.current) playback.pause(); @@ -74,6 +75,7 @@ function LoadedMediaVideo(props: { if (!signal.aborted && props.playRequested) await playIfUninterrupted(); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the video source into the native player and aborts the load on cleanup useEffect(() => { const controller = new AbortController(); // oxlint-disable-next-line react/set-state-in-effect -- Starting a native media load publishes pending before its async result. diff --git a/apps/mobile/src/components/ProjectFavicon.test.tsx b/apps/mobile/src/components/ProjectFavicon.test.tsx new file mode 100644 index 0000000000000..62eb2bb6b2ba4 --- /dev/null +++ b/apps/mobile/src/components/ProjectFavicon.test.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import { act, Profiler } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + Passthrough: ({ children }: { children?: unknown }) => children, +})); + +vi.mock("react-native", () => ({ View: harness.Passthrough })); +vi.mock("expo-image", () => ({ Image: () => })); +vi.mock("./ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("./ui/AppText", () => ({ + AppText: ({ children }: { children?: ReactNode }) => {children}, +})); +vi.mock("../state/assets", async () => { + const { Atom } = await import("effect/reactivity"); + const url = Atom.make("https://example.test/favicon.png"); + return { projectFaviconUrlAtom: () => url }; +}); + +import { EnvironmentId } from "@t3tools/contracts"; + +import { ProjectFavicon } from "./ProjectFavicon"; + +it("mounts the favicon image in the first commit", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.createElement("div"); + const root = createRoot(container); + const commits: string[] = []; + try { + await act(async () => + root.render( + commits.push(phase)}> + + , + ), + ); + expect(container.querySelector("img")).not.toBeNull(); + expect(commits).toEqual(["mount"]); + } finally { + await act(async () => root.unmount()); + } +}); diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 3e0f09741266f..85f5bbd55c6a9 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -163,14 +163,12 @@ function ProjectFaviconImage(props: { () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), [props.cacheKey, props.faviconUrl], ); - const [activeFaviconRequest, setActiveFaviconRequest] = useState(null); + // Registers with the module-level loader before the Image's native onLoad/onError can fire, + // so stale loads for a superseded URL under the same cacheKey are ignored. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- marks this favicon URL as the current request in the module registry before the native Image reports load or error useLayoutEffect(() => { if (faviconRequest === null) return; - - const endRequest = beginProjectFaviconRequest(faviconRequest); - // oxlint-disable-next-line react/set-state-in-effect -- Publishes the favicon request registered with the external loader until its cleanup runs. - setActiveFaviconRequest(faviconRequest); - return endRequest; + return beginProjectFaviconRequest(faviconRequest); }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => @@ -179,8 +177,7 @@ function ProjectFaviconImage(props: { : "loading", ); - const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; - const showImage = requestIsActive && status === "loaded"; + const showImage = faviconRequest !== null && status === "loaded"; const fallbackGlyph = showImage ? null : projectAutoMonogram(props.projectTitle); return ( @@ -203,7 +200,7 @@ function ProjectFaviconImage(props: { /> )} - {requestIsActive ? ( + {faviconRequest !== null ? ( { const controller = new AbortController(); let token: ReturnType | null = null; diff --git a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx index 1b259f1ecbfa1..7931717682fa1 100644 --- a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx @@ -1,7 +1,7 @@ import { AppActivityIndicator } from "./AppActivityIndicator"; import { Column, Row, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose"; import { weight } from "@expo/ui/jetpack-compose/modifiers"; -import { useState, type ReactNode } from "react"; +import { use, useState, type ReactNode } from "react"; import { View } from "react-native"; import type { AppSymbolName } from "./AppSymbol"; @@ -12,6 +12,7 @@ import { splitAndroidHeaderActions } from "./headerMenu.android"; import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; import { useToolbarLayout } from "./useToolbarLayout"; import { useControlTheme } from "./useControlTheme"; +import { RoutedFormSheetContext } from "./RoutedFormSheetContext"; export interface AndroidHeaderAction { readonly accessibilityLabel: string; @@ -50,7 +51,8 @@ export function AndroidScreenHeader(props: { const { colors } = useControlTheme(); const titleTypography = useScaledTextRole("title"); const subtitleTypography = useScaledTextRole("label"); - const { paddingTop, paddingBottom } = useToolbarLayout(props.embedded); + const inFormSheet = use(RoutedFormSheetContext); + const { paddingTop, paddingBottom } = useToolbarLayout(props.embedded || inFormSheet); const [headerWidth, setHeaderWidth] = useState(0); const { visibleActions, overflowActions } = splitAndroidHeaderActions( props.actions ?? [], @@ -67,6 +69,12 @@ export function AndroidScreenHeader(props: { borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > + {inFormSheet ? ( + + ) : null} { // Live regions reach TalkBack; VoiceOver needs an explicit announcement. if (Platform.OS === "ios") AccessibilityInfo.announceForAccessibility(props.message); diff --git a/apps/mobile/src/components/ui/OverlayPortal.tsx b/apps/mobile/src/components/ui/OverlayPortal.tsx index a4f07f46024f1..b5cd51e6c9058 100644 --- a/apps/mobile/src/components/ui/OverlayPortal.tsx +++ b/apps/mobile/src/components/ui/OverlayPortal.tsx @@ -38,6 +38,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { // happen after commit. No dependency array: re-project after every render so // the host always shows the current content (menus re-render while open — // drill-in, theme). + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- projects children into the out-of-tree overlay host store after every commit useEffect(() => { entries.set(key, props.children); emit(); @@ -45,6 +46,7 @@ export function OverlayPortal(props: { readonly children: ReactNode }) { // Removal is unmount-only: deleting on every re-projection would move the // entry to the end of the map and restack it above overlays opened later. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- removes this entry from the overlay host store on unmount useEffect( () => () => { entries.delete(key); diff --git a/apps/mobile/src/components/ui/RoutedFormSheetContext.ts b/apps/mobile/src/components/ui/RoutedFormSheetContext.ts new file mode 100644 index 0000000000000..29ee63d996e57 --- /dev/null +++ b/apps/mobile/src/components/ui/RoutedFormSheetContext.ts @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const RoutedFormSheetContext = createContext(false); diff --git a/apps/mobile/src/components/ui/Sheet.android.tsx b/apps/mobile/src/components/ui/Sheet.android.tsx index ac222485cf056..aaafffb8013da 100644 --- a/apps/mobile/src/components/ui/Sheet.android.tsx +++ b/apps/mobile/src/components/ui/Sheet.android.tsx @@ -3,6 +3,7 @@ import { View } from "react-native"; import { FormSheet } from "react-native-screens"; import { useResolveClassNames } from "uniwind"; import { AndroidSheetHeader } from "./AndroidScreenHeader"; +import { RoutedFormSheetContext } from "./RoutedFormSheetContext"; import { ScreenContent } from "./ScreenContent"; import type { SheetProps } from "./Sheet.types"; export type { SheetProps } from "./Sheet.types"; @@ -19,15 +20,21 @@ export function Sheet(props: SheetProps) { onDismiss={props.onClose} onNativeDismiss={props.onClose} > - {/* Android FormSheet does not bound flex children. Material drags target the first scroll view, so place content before the header. */} - - {props.children} - setIsOpen(false)} hideBottomBorder /> - - + + {/* Android FormSheet does not bound flex children. Material drags target the first scroll view, so place content before the header. */} + + {props.children} + setIsOpen(false)} + hideBottomBorder + /> + + + ); } diff --git a/apps/mobile/src/components/ui/Toast.tsx b/apps/mobile/src/components/ui/Toast.tsx index 4426cee5552af..7610a815195c9 100644 --- a/apps/mobile/src/components/ui/Toast.tsx +++ b/apps/mobile/src/components/ui/Toast.tsx @@ -55,6 +55,7 @@ function ToastContent(props: ToastProps & { readonly glassReady?: boolean }) { const action = props.action ? ( + ), + TextInput: (props: { + readonly accessibilityLabel?: string; + readonly value: string; + readonly onChangeText: (value: string) => void; + }) => ( + props.onChangeText(event.target.value)} + /> + ), + }; +}); +vi.mock("@legendapp/list/react-native", () => ({ + LegendList: (props: { + readonly data: ZipTreeRow[]; + readonly ListHeaderComponent: ReactNode; + readonly ListEmptyComponent: ReactNode; + readonly keyExtractor: (row: ZipTreeRow) => string; + readonly renderItem: (input: { item: ZipTreeRow }) => ReactNode; + }) => ( +
+ {props.ListHeaderComponent} + {props.data.length === 0 ? props.ListEmptyComponent : null} + {props.data.map((item) => ( +
{props.renderItem({ item })}
+ ))} +
+ ), +})); +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + Paths: { document: { uri: "file:///documents" } }, + File: class { + constructor(private readonly uri: string) {} + get exists() { + return false; + } + get size() { + return files.get(this.uri)?.byteLength ?? null; + } + async arrayBuffer() { + const bytes = files.get(this.uri); + if (!bytes) throw new Error(`Missing ZIP fixture: ${this.uri}`); + return Uint8Array.from(bytes).buffer; + } + }, +})); +vi.mock("expo/fetch", () => ({ fetch: vi.fn() })); +vi.mock("expo-crypto", () => ({ randomUUID: vi.fn() })); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ canGoBack: () => false }), + usePreventRemove: (enabled: boolean, remove: () => void) => { + navigation.remove = enabled ? remove : null; + }, +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); +vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: () => null })); +vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../components/ui/Toolbar", () => { + const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; + return { + Toolbar: Object.assign(Slot, { + Button: () => null, + Menu: Slot, + MenuAction: (props: { + readonly children?: ReactNode; + readonly disabled?: boolean; + readonly onPress?: () => void; + }) => ( + + ), + }), + }; +}); +vi.mock("../../components/ui/AndroidScreenHeader", () => ({ AndroidScreenHeader: () => null })); +vi.mock("../../components/ui/Menu", () => ({ Menu: () => null })); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../../components/ui/Progress", () => ({ Progress: () => null })); +vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("../../components/FilePreviewModal", () => ({ FilePreviewModal: () => null })); +vi.mock("../../lib/copyTextWithHaptic", () => ({})); +vi.mock("./SourceFileSurface", () => ({ + SourceFileSurface: (props: { readonly contents: string }) =>
{props.contents}
, +})); +vi.mock("./AudioFilePreview", () => ({ AudioFilePreview: () => null })); +vi.mock("./FileMarkdownPreview", () => ({ FileMarkdownPreview: () => null })); +vi.mock("./WorkspaceFileWebPreview", () => ({ WorkspaceFileWebPreview: () => null })); +vi.mock("../threads/HtmlRenderWebView", () => ({ HtmlRenderWebView: () => null })); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ + appearance: { codeWordBreak: false }, + themeVariables: { "--color-icon": "#000000" }, + setCodeWordBreak: () => {}, + }), +})); + +import { ZipArchiveView } from "./ZipArchiveView"; +import { AttachmentFileScreen } from "./AttachmentFileScreen"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { composerDraftsAtom } from "../../state/use-composer-drafts"; + +function setup() { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers({ toFake: ["setImmediate", "clearImmediate"] }); + files.clear(); + navigation.remove = null; + server.capabilities = { repositoryIdentity: false }; + server.refresh.mockClear(); + appAtomRegistry.set(composerDraftsAtom, {}); + files.set( + "file:///first.zip", + Uint8Array.from( + atob( + "UEsDBBQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAZmlyc3QudHh0Rmlyc3QgYXJjaGl2ZSBjb250ZW50c1BLAQIUAxQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAAAAAAAAAAACAAQAAAABmaXJzdC50eHRQSwUGAAAAAAEAAQA3AAAAPQAAAAAA", + ), + (character) => character.charCodeAt(0), + ), + ); + + const container = document.createElement("div"); + const root = createRoot(container); + onTestFinished(() => { + act(() => root.unmount()); + files.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + const props: ComponentProps = { + name: "archive.zip", + subtitle: "ZIP", + tintColor: "#000000", + sizeBytes: 0, + uri: "file:///first.zip", + isLocal: true, + refresh: async () => null, + environmentId: null, + draftKey: null, + maxAttachmentBytes: undefined, + onSave: () => {}, + onRemove: null, + }; + return { + container, + root, + props, + render: async (input = props) => { + await act(async () => root.render()); + }, + settle: async (assert: () => void) => { + await act(async () => vi.runAllTimersAsync()); + assert(); + }, + }; +} + +it("opens a ZIP chat attachment as a browsable archive on the attachment file screen", async () => { + const screen = setup(); + server.capabilities = { + repositoryIdentity: false, + attachmentUploads: true, + fileAttachments: { maxUploadBytes: 1024 }, + }; + const { fetch } = await import("expo/fetch"); + vi.mocked(fetch, { partial: true }).mockImplementation(async () => ({ + ok: true, + body: new Response(Uint8Array.from(files.get("file:///first.zip")!)).body, + })); + await act(async () => + screen.root.render( + , + ), + ); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + const add = Array.from(screen.container.querySelectorAll("button")).find( + (button) => button.textContent === "Add to chat", + ); + expect(add?.disabled).toBe(false); +}); + +it("keeps the ZIP filter and archive open when native Back closes a selected entry", async () => { + const screen = setup(); + await screen.render(); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + const filter = screen.container.querySelector( + '[aria-label="Filter files in ZIP"]', + )!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + filter, + "first", + ); + filter.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + + expect(navigation.remove).not.toBeNull(); + await act(async () => navigation.remove!()); + + expect(screen.container.querySelector("pre")).toBeNull(); + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(); + expect( + screen.container.querySelector('[aria-label="Filter files in ZIP"]')?.value, + ).toBe("first"); +}); + +it("explains why an encrypted ZIP cannot be browsed", async () => { + const screen = setup(); + const bytes = files.get("file:///first.zip")!; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const directory = view.getUint32(bytes.length - 6, true); + view.setUint16(directory + 8, view.getUint16(directory + 8, true) | 1, true); + await screen.render(); + await screen.settle(() => expect(screen.container.textContent).toContain("Encrypted zip")); + expect(screen.container.querySelector('[aria-label="first.txt"]')).toBeNull(); +}); + +it("keeps the archive and selected entry when the parent re-renders with a new refresh", async () => { + const screen = setup(); + await screen.render(); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + files.delete("file:///first.zip"); + + await screen.render({ ...screen.props, refresh: async () => null }); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + expect(navigation.remove).not.toBeNull(); +}); diff --git a/apps/mobile/src/features/files/ZipArchiveView.tsx b/apps/mobile/src/features/files/ZipArchiveView.tsx new file mode 100644 index 0000000000000..bb9931d4e3c00 --- /dev/null +++ b/apps/mobile/src/features/files/ZipArchiveView.tsx @@ -0,0 +1,870 @@ +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { + ARCHIVE_ENTRY_MAX_BYTES, + ARCHIVE_MAX_BYTES, + boundedZip, +} from "@t3tools/client-runtime/zip/bounded-zip"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; +import { + decodeFilePreviewText, + filePreviewKind, + hostPreviewMimeTypeFromExtension, +} from "@t3tools/shared/filePreview"; +import { LegendList } from "@legendapp/list/react-native"; +import { fetch } from "expo/fetch"; +import { File } from "expo-file-system"; +import { useNavigation, usePreventRemove } from "@react-navigation/native"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { Keyboard, Platform, Pressable, ScrollView, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { AppText as Text } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; +import { EmptyState } from "../../components/ui/EmptyState"; +import { Progress } from "../../components/ui/Progress"; +import { Toolbar } from "../../components/ui/Toolbar"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import * as Dialog from "../../components/ui/Dialog"; +import { FilePreviewModal } from "../../components/FilePreviewModal"; +import { + nativeViewerErrorMessage, + shareGeneratedAttachment, + writeGeneratedAttachmentFile, +} from "../../lib/attachmentDownload"; +import { attachmentDocumentPresentation } from "../../lib/attachmentDocumentPresentation"; +import { + createComposerFileAttachment, + removePersistedComposerAttachmentFile, +} from "../../lib/composerImages"; +import { appendComposerDraftAttachments } from "../../state/use-composer-drafts"; +import { AttachmentDocumentBody, nativeViewerKind } from "./AttachmentDocumentBody"; +import { FilePreviewLoading } from "./FilePreviewFeedback"; +import { + buildZipTree, + flattenZipTree, + zipDownloadTimeLeft, + zipFolderLabel, + type ZipEntry, + type ZipTreeRow, +} from "./zipTree"; + +export function isZipFile(file: { readonly name: string; readonly mimeType: string }) { + const mime = file.mimeType.split(";", 1)[0]?.trim().toLowerCase(); + return ( + mime === "application/zip" || + mime === "application/x-zip-compressed" || + file.name.toLowerCase().endsWith(".zip") + ); +} + +interface ZipArchive { + readonly entries: ReadonlyArray; + readonly fileCount: number; +} + +interface ZipFailure { + readonly title: string; + readonly detail: string; + readonly retry: boolean; +} + +const SAVE_ZIP_HINT = "Save the ZIP to open it in another app."; +const TOO_LARGE: ZipFailure = { + title: "Too large to browse here", + detail: `Browsing works up to ${formatAttachmentSize(ARCHIVE_MAX_BYTES)}. ${SAVE_ZIP_HINT}`, + retry: false, +}; +const CANCELLED: ZipFailure = { + title: "Download cancelled", + detail: "The ZIP wasn't downloaded. Try again to list its files.", + retry: true, +}; +const NETWORK: ZipFailure = { + title: "Couldn't download this ZIP", + detail: "Check the connection to this environment and try again.", + retry: true, +}; + +class ZipFailureError extends Error { + constructor(readonly failure: ZipFailure) { + super(failure.title); + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error ? cause.message : fallback; +} + +async function readArchiveBody( + body: ReadableStream, + onProgress: (receivedBytes: number) => void, +) { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + chunks.push(value); + onProgress(size); + } + } finally { + void reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes.buffer; +} + +async function openArchive(bytes: ArrayBuffer): Promise { + let files: Awaited>; + try { + files = await boundedZip(bytes); + } catch (cause) { + throw new ZipFailureError({ + title: "Couldn't browse this ZIP", + detail: `${errorMessage(cause, "It may be incomplete or damaged.")} ${SAVE_ZIP_HINT}`, + retry: false, + }); + } + const entries = [...files].map(([path, file]): ZipEntry => { + const name = path.split("/").findLast(Boolean) ?? path; + const dot = name.lastIndexOf("."); + return { + path, + name, + isDirectory: file.directory, + sizeBytes: file.bytes.byteLength, + mimeType: + (dot > 0 ? hostPreviewMimeTypeFromExtension(name.slice(dot).toLowerCase()) : null) ?? + "application/octet-stream", + bytes: file.bytes, + }; + }); + return { entries, fileCount: entries.filter((entry) => !entry.isDirectory).length }; +} + +type ArchiveState = + | { readonly status: "loading"; readonly receivedBytes: number; readonly timeLeft: string | null } + | { readonly status: "ready"; readonly archive: ZipArchive } + | { readonly status: "failed"; readonly failure: ZipFailure }; + +function useZipArchive(input: { + readonly uri: string | null; + readonly isLocal: boolean; + readonly sizeBytes: number; + readonly refresh: () => Promise; +}) { + const [state, setState] = useState({ + status: "loading", + receivedBytes: 0, + timeLeft: null, + }); + const [revision, setRevision] = useState(0); + const controller = useRef(null); + const { uri, isLocal, sizeBytes } = input; + // The parent mints a new refresh callback on every render; it must not reload the archive. + const refresh = useEffectEvent(input.refresh); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Load archive bytes and cancel the stream on cleanup. + useEffect(() => { + if (!uri) return; + const abort = new AbortController(); + controller.current = abort; + const startedAt = Date.now(); + // oxlint-disable-next-line react/set-state-in-effect -- A new archive load resets its progress. + setState({ status: "loading", receivedBytes: 0, timeLeft: null }); + void (async () => { + if (sizeBytes > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + if (isLocal) { + const file = new File(uri); + if ((file.size ?? 0) > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + return openArchive(await file.arrayBuffer()); + } + const url = (await refresh()) ?? uri; + const response = await fetch(url, { signal: abort.signal }); + if (!response.ok || !response.body) throw new ZipFailureError(NETWORK); + const bytes = await readArchiveBody(response.body, (receivedBytes) => + setState({ + status: "loading", + receivedBytes, + timeLeft: zipDownloadTimeLeft({ + receivedBytes, + totalBytes: sizeBytes, + elapsedMs: Date.now() - startedAt, + }), + }), + ); + abort.signal.throwIfAborted(); + return openArchive(bytes); + })() + .then((archive) => { + if (!abort.signal.aborted) setState({ status: "ready", archive }); + }) + .catch((cause: unknown) => { + if (!abort.signal.aborted) + setState({ + status: "failed", + failure: cause instanceof ZipFailureError ? cause.failure : NETWORK, + }); + }); + return () => abort.abort(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reload the archive. + }, [uri, isLocal, sizeBytes, revision]); + return { + state, + retry: () => setRevision((value) => value + 1), + cancel: () => { + controller.current?.abort(); + setState({ status: "failed", failure: CANCELLED }); + }, + }; +} + +function ZipTreeRowView(props: { + readonly row: ZipTreeRow; + readonly collapsed: boolean; + readonly onToggle: (path: string) => void; + readonly onOpen: (entry: ZipEntry) => void; +}) { + const { node, depth } = props.row; + const entry = node.entry; + const folder = entry ? null : zipFolderLabel(node.label); + return ( + (entry ? props.onOpen(entry) : props.onToggle(node.path))} + className="mx-2 h-11 flex-row items-center gap-1.5 rounded-lg pr-2.5 active:bg-subtle" + style={{ paddingLeft: 8 + depth * 18 }} + > + + {entry ? null : ( + + )} + + + + + + {folder ? ( + <> + {folder.prefix} + {folder.name} + + ) : ( + node.label + )} + + + {entry + ? formatAttachmentSize(entry.sizeBytes) + : props.collapsed + ? `${node.fileCount} ${node.fileCount === 1 ? "file" : "files"}` + : ""} + + + ); +} + +function ZipArchiveHeader(props: { + readonly name: string; + readonly subtitle: string; + readonly tintColor: string; + readonly onSave: () => void; + readonly onRemove: (() => void) | null; +}) { + const navigation = useNavigation(); + const isAndroid = Platform.OS === "android"; + return ( + <> + + {isAndroid ? ( + { + if (navigation.canGoBack()) navigation.goBack(); + }} + actions={[ + { accessibilityLabel: "Save ZIP", icon: "square.and.arrow.up", onPress: props.onSave }, + ...(props.onRemove + ? [ + { + accessibilityLabel: "Remove from draft", + icon: "trash" as const, + onPress: props.onRemove, + destructive: true, + overflowOnly: true, + }, + ] + : []), + ]} + /> + ) : ( + + {props.onRemove ? ( + + + Save ZIP + + + Remove from draft + + + ) : ( + + )} + + )} + + ); +} + +function ZipArchiveProgress(props: { + readonly receivedBytes: number; + readonly totalBytes: number; + readonly timeLeft: string | null; + readonly onCancel: () => void; +}) { + const { timeLeft } = props; + return ( + + + {formatAttachmentSize(props.receivedBytes)} + {props.totalBytes > 0 ? ( + + {` of ${formatAttachmentSize(props.totalBytes)}`} + + ) : null} + + 0 ? props.receivedBytes / props.totalBytes : undefined} + label="ZIP download progress" + /> + + {`Downloading the ZIP so its files can be listed.${timeLeft ? ` ${timeLeft} on this connection.` : ""}`} + + + + + ) : ( +

+ Only a session that manages access here can answer this. +

+ )} + + + ); +} + +/** + * What the dialog links: a machine from the client's catalog, or an address. + * `urls` is empty when the client knows no direct address for the machine, + * so the user types one. + */ +type DialogTarget = + | { readonly kind: "none" } + | { + readonly kind: "machine"; + readonly environmentId: EnvironmentId; + readonly label: string; + readonly urls: ReadonlyArray; + readonly linked: boolean; + } + | { readonly kind: "address" }; + +function resolveTarget( + item: LinkRequestItem, + machines: ReadonlyArray, + choice: string | null, +): DialogTarget { + if (item.environmentId === undefined && item.url !== undefined) return { kind: "address" }; + const chosen = item.environmentId ?? choice; + if (chosen === null) return { kind: "none" }; + if (chosen === ANOTHER_ADDRESS) return { kind: "address" }; + const machine = machines.find((candidate) => candidate.environmentId === chosen); + return { + kind: "machine", + environmentId: machine?.environmentId ?? (chosen as EnvironmentId), + label: machine?.label ?? linkRequestTargetName(item), + urls: [...new Set([...(machine?.urls ?? []), ...(item.url === undefined ? [] : [item.url])])], + linked: machine?.linked ?? false, + }; +} + +/** The dialog's steps: where to link, then what agents may do there. */ +type LinkStep = "machine" | "access"; + +function LinkRequestDialog(props: { + readonly environmentId: EnvironmentId; + readonly item: LinkRequestItem; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + const { item } = props; + const formId = useId(); + const pickMachine = picksMachine(item); + const listed = useLinkableMachines(props.environmentId, props.open); + const machines = rankMachinesByHint(listed, item.hint); + // Null until the user picks; until then the hint's best match is picked. + const [choice, setChoice] = useState(null); + const [address, setAddress] = useState(item.url ?? ""); + const [access, setAccess] = useState(() => linkRequestDefaultAccess(item)); + const [pairingCode, setPairingCode] = useState(""); + const [step, setStep] = useState("machine"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Machines whose minting already failed, so the dialog asks for a code instead. + const [mintFailed, setMintFailed] = useState>(new Set()); + // Enter then a click can both run before a re-render; this guard is synchronous. + const inFlight = useRef(false); + const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + label: "answer link request", + // The failure cause holds the request, and with it the code; keep it out of the console. + reportFailure: false, + reportDefect: false, + }); + const selected = choice ?? linkRequestInitialMachine(item, machines); + const target = resolveTarget(item, machines, selected); + const targetName = target.kind === "machine" ? target.label : linkRequestTargetName(item); + // This client can mint a code there when it is connected to that + // environment with a session that may create pairing links. + const targetId = target.kind === "machine" ? target.environmentId : null; + const connected = usePreparedConnection(targetId); + const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope); + const canMint = + targetId !== null && Option.isSome(connected) && mayMint && !mintFailed.has(targetId); + const needsAddress = + target.kind === "address" || (target.kind === "machine" && target.urls.length === 0); + // An address the agent gave is already filled in; only one the user must type is asked for. + const asksAddress = needsAddress && !(item.url !== undefined && item.environmentId === undefined); + const useExisting = target.kind === "machine" && target.linked; + // A named machine with an address it answers at has nothing to choose before access. + const steps: ReadonlyArray = + pickMachine || asksAddress ? ["machine", "access"] : ["access"]; + const current: LinkStep = steps.includes(step) ? step : "access"; + const machineReady = + target.kind !== "none" && (useExisting || !needsAddress || address.trim().length > 0); + const ready = machineReady && (useExisting || canMint || pairingCode.trim().length > 0); + + const reset = () => { + setChoice(null); + setAddress(item.url ?? ""); + setAccess(linkRequestDefaultAccess(item)); + setPairingCode(""); + setStep("machine"); + setError(null); + }; + + // Every close resets, so a reopened dialog never shows the last answer or its pairing code. + const close = () => { + props.onOpenChange(false); + reset(); + }; + + // Settles to the answer to send, an error to show, or nothing to do. + const prepareAnswer = async () => { + if (target.kind === "none") return null; + if (target.kind === "machine" && target.linked) { + return linkRequestAnswerInput(item, { + type: "use-existing", + environmentId: target.environmentId, + }); + } + let code = pairingCode; + if (canMint && targetId !== null) { + const minted = await mintPairingCodeOn(targetId, access); + if (Result.isFailure(minted)) { + setMintFailed((failed) => new Set(failed).add(targetId)); + setError(linkRequestFailureMessage(minted.failure)); + return null; + } + code = minted.success; + } + return linkRequestAnswerInput(item, { + type: "link", + target: + target.kind === "address" + ? { url: address } + : { ...target, urls: needsAddress ? [address.trim()] : target.urls }, + access, + pairingCode: code, + }); + }; + + const submit = async () => { + if (inFlight.current || target.kind === "none") return; + inFlight.current = true; + setBusy(true); + setError(null); + const input = await prepareAnswer(); + const result = + input === null ? null : await answer({ environmentId: props.environmentId, input }); + inFlight.current = false; + setBusy(false); + if (result === null) return; + if (result._tag === "Success") { + // The card switches to its answered row once the item updates. + close(); + } else if (!isAtomCommandInterrupted(result)) { + setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + } + }; + + // The machine step ends the dialog when the machine is linked here already. + const finishesHere = current === "access" || useExisting; + + return ( + { + // An answer that lands after a close would still link, and would close a reopened dialog. + if (!next && busy) return; + if (next) props.onOpenChange(true); + else close(); + }} + > + + + {steps.length > 1 && !useExisting ? ( + busy || (index === 1 && !machineReady)} + onStepChange={(index) => { + const next = steps[index]; + if (next !== undefined) setStep(next); + }} + /> + ) : null} + + +
{ + event.preventDefault(); + if (finishesHere) void submit(); + else if (machineReady) setStep("access"); + }} + > + {current === "machine" ? ( + <> + {pickMachine ? ( + { + setChoice(next); + setError(null); + }} + /> + ) : null} + {asksAddress ? ( + + ) : null} + + ) : ( + <> + {item.url !== undefined && !pickMachine ? ( +

{item.url}

+ ) : null} + {useExisting ? null : ( + <> + {canMint ? null : ( + + )} + + + )} + + )} + {useExisting ? ( +

+ This environment is already linked to {targetName}. The agent can use that link as + it is; change its access in Settings → Connections. +

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

+ {error} +

+ ) : null} + +
+ + {current === "access" && steps.length > 1 ? ( + + ) : ( + + )} + + +
+
+ ); +} + +/** The user's other machines, best match for the agent's hint first, then an address. */ +function MachinePicker(props: { + readonly machines: ReadonlyArray; + readonly value: string | null; + readonly disabled: boolean; + readonly onChange: (value: string) => void; +}) { + const labelId = useId(); + return ( +
+ + Machine + + props.onChange(next as string)} + > + {props.machines.map((machine) => ( + + ))} + + +
+ ); +} diff --git a/apps/web/src/components/chat/MarkdownCodeBlock.tsx b/apps/web/src/components/chat/MarkdownCodeBlock.tsx index 101c8a05d3eea..6b1822aac14a7 100644 --- a/apps/web/src/components/chat/MarkdownCodeBlock.tsx +++ b/apps/web/src/components/chat/MarkdownCodeBlock.tsx @@ -331,6 +331,7 @@ function UncachedShikiCodeBlock({ const incrementalHighlight = preserveLines ? createIncrementalHighlightedDocument(highlighter, language, themeName) : null; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- disposes the incremental Shiki highlighter when it is replaced or the block unmounts useEffect(() => () => incrementalHighlight?.dispose(), [incrementalHighlight]); const highlighted = (() => { const result = (() => { diff --git a/apps/web/src/components/chat/MarkdownFileLink.tsx b/apps/web/src/components/chat/MarkdownFileLink.tsx index 872ce0de8dc25..6a250f93d2157 100644 --- a/apps/web/src/components/chat/MarkdownFileLink.tsx +++ b/apps/web/src/components/chat/MarkdownFileLink.tsx @@ -5,13 +5,13 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -import { memo, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react"; +import { memo, type MouseEvent as ReactMouseEvent } from "react"; import { readLocalApi } from "../../localApi"; import { cn } from "../../lib/utils"; import { shouldOpenMarkdownFileLinkInEditor } from "../../markdown-links"; import type { markdownFileCopyPaths } from "../../markdownFileHostPath"; -import { documentIconColorForFileName } from "../../pierre-icons"; +import { documentChipKindForFileName } from "../../pierre-icons"; import { ContextChip } from "../ContextChip"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -321,8 +321,7 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ canOpenInBrowser, canOpenInPanel, }); - const accent = documentIconColorForFileName(iconPath); - const chipStyle = accent ? ({ "--context-chip-accent": accent } as CSSProperties) : undefined; + const chipKind = documentChipKindForFileName(iconPath) ?? "mention"; return ( @@ -330,11 +329,10 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ render={ hasPrimaryAction ? ( } className={MARKDOWN_FILE_LINK_CLASS_NAME} - style={chipStyle} data-markdown-copy={copyMarkdown} onClick={(event) => { event.preventDefault(); @@ -360,13 +358,12 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ ) : ( } aria-label={`File options for ${label}`} aria-haspopup="menu" className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} - style={chipStyle} data-markdown-copy={copyMarkdown} onClick={handleContextMenu} onContextMenu={handleContextMenu} diff --git a/apps/web/src/components/chat/MarkdownTable.tsx b/apps/web/src/components/chat/MarkdownTable.tsx index dbeafeb5dcc4a..9859ff97ad5eb 100644 --- a/apps/web/src/components/chat/MarkdownTable.tsx +++ b/apps/web/src/components/chat/MarkdownTable.tsx @@ -3,7 +3,6 @@ import { useRef, useState, type ComponentProps } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { getClientSettings } from "../../hooks/useSettings"; -import { observeResize } from "../../lib/observeResize"; import { serializeTableElementToCsv, serializeTableElementToMarkdown, @@ -15,35 +14,9 @@ import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { reportMarkdownActionFailure } from "./markdownActionFailure"; -/** - * Whether any cell is clipped in the unwrapped layout. Only then does the wrap - * toggle change anything, so short tables hide it. Measures with the container - * briefly unwrapped and restores it before the browser paints. - */ -function tableHasClippedCells(container: HTMLElement, table: HTMLTableElement): boolean { - const viewport = table.closest('[data-slot="scroll-area-viewport"]'); - const scrollLeft = viewport?.scrollLeft ?? 0; - const previous = container.dataset.expanded; - container.dataset.expanded = "false"; - try { - for (const row of table.rows) { - for (const cell of row.cells) { - if (cell.scrollWidth > cell.clientWidth) return true; - } - } - return false; - } finally { - if (previous === undefined) delete container.dataset.expanded; - else container.dataset.expanded = previous; - // The unwrapped layout can be narrower and clamp a sideways scroll. - if (viewport && scrollLeft > 0) viewport.scrollLeft = scrollLeft; - } -} - export function MarkdownTable({ children, ...props }: ComponentProps<"table">) { const containerRef = useRef(null); const [expanded, setExpanded] = useState(() => getClientSettings().wordWrap); - const [hasClippedCells, setHasClippedCells] = useState(false); const { copyToClipboard, isCopied: copied } = useCopyToClipboard<"markdown" | "csv">({ timeout: 1200, onError: (cause, format) => @@ -52,25 +25,6 @@ export function MarkdownTable({ children, ...props }: ComponentProps<"table">) { const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; const copyLabel = copied ? "Copied" : "Copy table"; - // React calls this with null when a previous attach returned no cleanup. - const attachContainer = (container: HTMLDivElement | null) => { - containerRef.current = container; - const table = container?.querySelector("table"); - if (!container || !table) return; - const measure = () => setHasClippedCells(tableHasClippedCells(container, table)); - measure(); - // A streamed cell can grow until it clips while the table keeps its size, and a - // narrower column can clip a cell without this component rendering. - const mutations = new MutationObserver(measure); - mutations.observe(table, { childList: true, characterData: true, subtree: true }); - const stopResize = observeResize(table, measure); - return () => { - mutations.disconnect(); - stopResize(); - containerRef.current = null; - }; - }; - const handleCopy = (format: "markdown" | "csv") => { const table = containerRef.current?.querySelector("table"); if (!table) return; @@ -84,7 +38,7 @@ export function MarkdownTable({ children, ...props }: ComponentProps<"table">) { return (
@@ -93,27 +47,23 @@ export function MarkdownTable({ children, ...props }: ComponentProps<"table">) { {/* Stays within the row's px-1: the chat lane clips sideways overflow. */}
- {hasClippedCells ? ( - - setExpanded((value) => !value)} - aria-label={expandLabel} - /> - } - > - - - {expandLabel} - - ) : ( - - )} + + setExpanded((value) => !value)} + aria-label={expandLabel} + /> + } + > + + + {expandLabel} + { hostRef.current?.updateHostContext(); // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend. }, [theme, width, displayMode, toolDefinition]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- sends the current tool call to the MCP app host, again for each new iframe document useEffect(() => { if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall); // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call. diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index fbfa67e61bf8e..07bc698e02cec 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -151,7 +151,7 @@ function TimelineListFooter({ readonly children?: ReactNode; }) { const contentRef = useRef(null); - // oxlint-disable-next-line t3code/react-effect-budget -- reports growth of the footer's content, which the list does not follow + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports growth of the footer's content, which the list does not follow useLayoutEffect(() => { const content = contentRef.current; if (!content || !onGrow) return; @@ -237,9 +237,12 @@ interface MessagesTimelineProps { onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; renderHtmlRender?: TimelineRowSharedState["renderHtmlRender"]; turnItemSource?: TimelineRowSharedState["turnItemSource"]; + /** The thread this one is a subagent of; `open` is absent when it cannot be opened from here. */ parentThreadLink?: { - readonly threadId: ThreadId; readonly title: string; + /** Where it runs, when that is another environment. */ + readonly environmentLabel?: string; + readonly open?: () => void; } | null; workflowOpening?: WorkflowOpening | null; onForkFromRun?: (input: { @@ -701,6 +704,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ // must land before paint, after LegendList's own layout effects applied the // new data. A citation request that arrives mid-restore takes over. const { findOpenAtOpening } = identityChanged ? { findOpenAtOpening: findOpen } : listState; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores LegendList's scroll position for the shown thread before paint, after the list's own layout useLayoutEffect(() => { const list = listRef.current; const viewport = viewportRef.current; @@ -1093,10 +1097,15 @@ const ConversationTimeline = memo(function ConversationTimeline({
onOpenThread(parentThreadLink.threadId)} + {...(parentThreadLink.open === undefined + ? {} + : { actionLabel: "Open parent thread", onAction: parentThreadLink.open })} />
diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 605d8d43e3950..e745f249acd1a 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -303,6 +303,7 @@ function ModelPickerList({ setShowBottomScrollFade(itemKeys.length > 5); } const measureScrollFades = useEffectEvent(updateScrollFades); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the list scroller in animation frames after a new list lays out to set scroll fades useLayoutEffect(() => { let nestedFrame = 0; const frame = window.requestAnimationFrame(() => { @@ -353,6 +354,7 @@ function ModelPickerList({ // Capture gives picker shortcuts priority over the composer. function WindowKeyDownListener({ onKeyDown }: { onKeyDown: (event: KeyboardEvent) => void }) { const handleKeyDown = useEffectEvent(onKeyDown); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a capture-phase window keydown listener for the picker's lifetime useEffect(() => { const listener = (event: KeyboardEvent) => handleKeyDown(event); window.addEventListener("keydown", listener, true); diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 0dca95cf02b94..3d98646e04e86 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -50,6 +50,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const [hoveredInstanceId, setHoveredInstanceId] = useState(null); const sidebarContentRef = useRef(null); const [selectedIndicatorTop, setSelectedIndicatorTop] = useState(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads the selected provider item's DOM offset to position the selection indicator useLayoutEffect(() => { const content = sidebarContentRef.current; if (!content) { diff --git a/apps/web/src/components/chat/OpenInPickerShortcut.ts b/apps/web/src/components/chat/OpenInPickerShortcut.ts index 4def43be31587..8c8249c67b1a0 100644 --- a/apps/web/src/components/chat/OpenInPickerShortcut.ts +++ b/apps/web/src/components/chat/OpenInPickerShortcut.ts @@ -52,6 +52,7 @@ export function useOpenFavoriteEditorShortcut({ const [preferredEditor] = usePreferredEditor(availableEditors); const remote = useRemoteOpenState(environmentId); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a window keydown listener for the open-in-editor shortcut while enabled locally useEffect(() => { if (!enabled || remote.mode !== "local-exec") return; const handler = (event: globalThis.KeyboardEvent) => { diff --git a/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx b/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx index 5a31db26cf7cc..6a3a5d98131f9 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx @@ -1,5 +1,6 @@ import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import * as DateTime from "effect/DateTime"; import type { ComponentProps } from "react"; import { expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; @@ -17,6 +18,15 @@ vi.mock(import("@t3tools/client-runtime/state/thread-workflows"), async (importO vi.mock(import("../../state/entities"), async (importOriginal) => ({ ...(await importOriginal()), useThreadProjection: (() => state.projection) as never, + useQuietThreadShell: ((target: unknown) => + target === null ? null : { title: "Release lead" }) as never, +})); + +vi.mock(import("../../state/query"), async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentQuery: (() => ({ + data: { tasks: [{ id: "task:webhook", title: "PR dispatcher" }] }, + })) as never, })); vi.mock(import("../../state/use-atom-command"), async (importOriginal) => ({ @@ -46,6 +56,8 @@ const baseProps = { editBlockedReason: null, onEditQueuedRun: () => undefined, onCancelEdit: () => undefined, + timestampFormat: "locale", + onOpenThread: () => undefined, } satisfies ComponentProps; async function renderQueue(props: Partial> = {}) { @@ -97,3 +109,66 @@ it("previews assistant quotes as readable text and preserves the citation when e expect.objectContaining({ text: quoteAndText }), ); }); + +it("shows who sent a queued message and folds automation and agent prompts", async () => { + setup(); + const message = (id: string, fields: Record) => ({ + id, + role: "user", + createdAt: DateTime.makeUnsafe("2026-10-27T12:00:00.000Z"), + ...fields, + }); + state.projection = { projection: { messages: [] } }; + state.workflow = { + activeRun: { id: "run:active" }, + canPromoteToSteer: true, + queuedRuns: [ + [ + "run:automation", + "GitHub labeled a pull request.\nFollow the runbook.", + { + createdBy: "agent", + scheduledTaskId: "task:webhook", + }, + ], + [ + "run:agent", + "Review finished.", + { + createdBy: "agent", + creationSource: "provider", + senderThreadId: "thread:lead", + }, + ], + [ + "run:server", + "Continue where you left off.", + { + createdBy: "agent", + creationSource: "server", + }, + ], + ].map(([runId, text, fields]) => ({ + run: { id: runId, userMessageId: `${runId}:message` }, + text, + attachments: [], + message: message(`${runId}:message`, { text, ...(fields as object) }), + })), + }; + const onOpenThread = vi.fn(); + await using _screen = await renderQueue({ onOpenThread }); + + const automation = page.getByRole("button", { name: /PR dispatcher/ }); + await expect.element(automation).toHaveAttribute("aria-expanded", "false"); + await expect.element(page.getByText(/Follow the runbook/)).not.toBeInTheDocument(); + await automation.click(); + await expect.element(page.getByText(/Follow the runbook/)).toBeVisible(); + await expect.element(page.getByRole("link", { name: "Open automation" })).toBeInTheDocument(); + + await expect.element(page.getByRole("button", { name: /Release lead/ })).toBeVisible(); + await page.getByRole("button", { name: "Open thread" }).click({ force: true }); + expect(onOpenThread).toHaveBeenCalledExactlyOnceWith("thread:lead"); + + await expect.element(page.getByText("Sent by T3 Code")).toBeVisible(); + await expect.element(page.getByText("Continue where you left off.")).toBeVisible(); +}); diff --git a/apps/web/src/components/chat/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index 763d3ab435e00..850c38df79f4c 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -9,18 +9,32 @@ import type { RunId, ThreadId, } from "@t3tools/contracts"; -import { ChevronDownIcon, Clock3Icon, ArrowUpIcon, PencilIcon, XIcon } from "lucide-react"; -import { useId, useImperativeHandle, useRef, useState, type Ref } from "react"; +import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; +import { Link } from "@tanstack/react-router"; +import { + ArrowUpIcon, + CalendarClockIcon, + ChevronDownIcon, + Clock3Icon, + MessagesSquareIcon, + PencilIcon, + XIcon, +} from "lucide-react"; +import * as DateTime from "effect/DateTime"; +import { useId, useImperativeHandle, useRef, useState, type ReactNode, type Ref } from "react"; import { useAssetUrls } from "../../assets/assetUrls"; import { threadEnvironment } from "../../state/threads"; -import { useThreadProjection } from "../../state/entities"; +import { useQuietThreadShell, useThreadProjection } from "../../state/entities"; import { useAtomCommand } from "../../state/use-atom-command"; import { isImageAttachment, type ChatMessage } from "../../types"; import { cn } from "~/lib/utils"; import { Button, InlineButton } from "../ui/button"; -import { Separator } from "../ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { GuestMessageName } from "./guestMessageStyle"; +import { TimelineRowTimestamp } from "./TimelineRowParts"; +import { FoldedMessageHeader, useScheduledTaskTitle } from "./TimelineUserMessageRow"; export interface EditQueuedRunRequest { readonly runId: RunId; @@ -73,6 +87,8 @@ export function QueuedRunsControl({ readonly editBlockedReason: string | null; readonly onEditQueuedRun: (request: EditQueuedRunRequest) => void; readonly onCancelEdit: () => void; + readonly timestampFormat: TimestampFormat; + readonly onOpenThread: (threadId: ThreadId) => void; }) { const projection = useThreadProjection( scopeThreadRef(props.environmentId, props.threadId), @@ -105,15 +121,15 @@ export function QueuedRunsControl({ (message) => message.inputIntent === "queued_turn" && !acknowledgedMessageIds.has(message.id), ); const items = [ - ...queued.map(({ run, text, attachments, context }, serverIndex) => ({ + ...queued.map(({ run, text, attachments, context, message }, serverIndex) => ({ key: run.id, + message, runId: run.id, messageId: run.userMessageId, serverIndex, text, // A guest's message keeps the guest's words; the owner can cancel it, not rewrite it. editable: context?.guest === undefined, - guestName: context?.guest?.name, attachments, thumbnails: attachments .filter((attachment) => attachment.type === "image") @@ -126,12 +142,12 @@ export function QueuedRunsControl({ })), ...optimisticQueued.map((message) => ({ key: message.id, + message: undefined, runId: null, messageId: null, serverIndex: null, text: message.text, editable: true, - guestName: undefined, attachments: [] as ReadonlyArray, thumbnails: (message.attachments ?? []) .filter(isImageAttachment) @@ -233,51 +249,17 @@ export function QueuedRunsControl({ aria-posinset={position + 1} aria-setsize={items.length} aria-current={isEditing ? "true" : undefined} - className="flex w-full flex-col items-end gap-1 transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none" + className="group/queued flex w-full flex-col items-end gap-1 transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none" > - {/* Dashed and unfilled: a turn that has not been sent yet. */} -
- {item.thumbnails.length > 0 ? ( - - {item.thumbnails.map((thumbnail) => ( - - {thumbnail.url ? ( - {thumbnail.name} - ) : ( - - )} - - ))} - - ) : null} - {/* In full, like a sent turn: a queued message is read before it is sent. */} - {previewText ? ( -

- {item.guestName === undefined ? null : ( - {item.guestName} - )} - {previewText} -

- ) : null} -
-
{canSend && !isEditing ? ( <> @@ -300,7 +282,6 @@ export function QueuedRunsControl({ {`${workflow?.canSendNow ? "Start a turn with this message" : workflow?.promoteSkipsCompaction ? "Skip compaction and send this message" : "Send into the current turn"}${item.serverIndex === 0 && props.steerShortcutLabel ? ` (${props.steerShortcutLabel})` : ""}`} - ) : null} {item.pending ? ( @@ -375,7 +356,7 @@ export function QueuedRunsControl({ )} -
+ ); })} @@ -404,3 +385,141 @@ export function QueuedRunsControl({ ); } + +type QueuedRunMessage = NonNullable; + +/** A queued turn wears its sent form's attribution; only the dashed edge and controls differ. */ +function QueuedMessage(props: { + readonly environmentId: EnvironmentId; + readonly message: QueuedRunMessage | undefined; + readonly previewText: string; + readonly thumbnails: ReadonlyArray; + readonly isEditing: boolean; + readonly timestampFormat: TimestampFormat; + readonly onOpenThread: (threadId: ThreadId) => void; + readonly children: ReactNode; +}) { + const presentation = props.message ? resolveUserMessagePresentation(props.message) : null; + const attribution = presentation?.attribution ?? null; + const scheduledTaskId = presentation?.scheduledTaskId; + const senderThreadId = props.message?.senderThreadId; + const [expanded, setExpanded] = useState(false); + const taskTitle = useScheduledTaskTitle(props.environmentId, scheduledTaskId); + const senderShell = useQuietThreadShell( + attribution === "agent" && senderThreadId + ? { environmentId: props.environmentId, threadId: senderThreadId } + : null, + ); + const fold = + attribution === "automation" + ? { + icon: CalendarClockIcon, + label: "Automation", + summary: taskTitle ?? props.previewText.split(/\r?\n/, 1)[0] ?? "", + } + : attribution === "agent" + ? { + icon: MessagesSquareIcon, + label: senderShell?.title.trim() || "Agent", + summary: props.previewText.split(/\r?\n/).find((line) => line.trim()) ?? "", + } + : null; + const bodyVisible = fold === null || expanded; + + return ( + <> + {attribution === "t3code" ? ( +

Sent by T3 Code

+ ) : null} + {presentation?.guestName === undefined ? null : ( + + )} + {/* Dashed and unfilled: a turn that has not been sent yet. */} +
+ {fold ? ( + setExpanded((value) => !value)} + /> + ) : null} + {bodyVisible ? ( +
+ {props.thumbnails.length > 0 ? ( + + {props.thumbnails.map((thumbnail) => ( + + {thumbnail.url ? ( + {thumbnail.name} + ) : ( + + )} + + ))} + + ) : null} + {/* In full, like a sent turn: a queued message is read before it is sent. */} + {props.previewText ? ( +

{props.previewText}

+ ) : null} +
+ ) : null} +
+
+ {props.message ? ( + + {attribution === "agent" && senderThreadId ? ( + props.onOpenThread(senderThreadId)}> + Open thread + + ) : null} + {scheduledTaskId ? ( + + } + > + Open automation + + ) : null} + + + ) : null} + {props.children} +
+ + ); +} diff --git a/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx b/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx index d82ec08f41cf9..998b9e1522ad5 100644 --- a/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx +++ b/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx @@ -25,6 +25,7 @@ export const ThreadActivityAnnouncer = memo(function ThreadActivityAnnouncer({ const { regionRef, announce, clear } = useLiveRegion(); const memoryRef = useRef<{ threadKey: string; memory: ThreadAnnouncementMemory } | null>(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes thread status changes to the aria-live region after commit useEffect(() => { if (threadKey === null) { memoryRef.current = null; diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 42170a1147668..bd4fde37a132e 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -48,6 +48,7 @@ export function ThreadDetailsCard({ // inline flag, which live in three stores, so no single write site sees the // card leave popover mode. A flag left set would reopen the popover unasked // when the card returns to popover mode. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- closes the popover flag in the right panel store when the derived mode turns inline useLayoutEffect(() => { if (mode === "inline" && popoverOpen) useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", false); @@ -56,6 +57,7 @@ export function ThreadDetailsCard({ const { environmentId, threadId } = threadRef; // Re-measures when the density or measurement key changes, so each density's // height is measured once it renders. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the card's DOM height via ResizeObserver and reports it to the canvas useLayoutEffect(() => { const element = contentElement; if (!element || density === "essential") return; diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 3e62dd8cd5a44..e51f62f34b056 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -22,6 +22,7 @@ import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; +import { ThreadServersPanel } from "./ThreadServersPanel"; const GitActionsControl = memo(BaseGitActionsControl); @@ -181,6 +182,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { ) : null} + {density === "full" && !props.draftId ? ( + + ) : null} + {density === "full" && !props.draftId ? ( ) : null} diff --git a/apps/web/src/components/chat/ThreadFindBar.tsx b/apps/web/src/components/chat/ThreadFindBar.tsx index aca1d6ca0405b..466cd929a7580 100644 --- a/apps/web/src/components/chat/ThreadFindBar.tsx +++ b/apps/web/src/components/chat/ThreadFindBar.tsx @@ -26,6 +26,7 @@ export const THREAD_FIND_BAR_RESERVED_HEIGHT = 36 + THREAD_DETAILS_CARD_GAP; export function ThreadFindBar(props: ThreadFindBarProps) { const inputRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses and selects the input DOM node when the bar opens or a find shortcut fires useEffect(() => { if (!props.open) return; inputRef.current?.focus(); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 66fc6b6f21ec2..b37f9d1d1078d 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -32,8 +32,10 @@ import { isProviderNativeSubagentThread, NodeId, type EnvironmentId, + type OrchestrationV2RemoteTaskChild, type OrchestrationV2Subagent, type OrchestrationV2ThreadShell, + type ServerProvider, type ThreadId, } from "@t3tools/contracts"; import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; @@ -61,7 +63,7 @@ import { useThreadProjection, useThreadRelationshipShells, } from "../../state/entities"; -import { useEnvironmentProviders } from "../../state/environments"; +import { useEnvironment, useEnvironmentProviders } from "../../state/environments"; import { threadEnvironment } from "../../state/threads"; import { orchestrationEnvironment } from "../../state/orchestration"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -217,6 +219,74 @@ function currentSubagent( }; } +/** + * A task delegated to a linked environment. It has no thread here, so it opens + * the thread there, when this client is connected to that environment. + */ +function RemoteSubagentRow(props: { + readonly agent: OrchestrationV2Subagent & { + readonly remoteChild: OrchestrationV2RemoteTaskChild; + }; + readonly provider: ServerProvider | undefined; +}) { + const navigate = useNavigate(); + const environment = useEnvironment(props.agent.remoteChild.environmentId); + const { remoteChild } = props.agent; + const status = props.agent.status; + const failed = status === "failed"; + const title = relationshipThreadTitle({ + title: props.agent.title ?? "Subagent", + isSubagent: true, + }); + return ( +
  • + + + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(remoteChild.environmentId, remoteChild.threadId), + ), + }) + } + /> + } + > + + + + {title} + + + + on {remoteChild.label} · {threadRelationshipStatusLabel(status)} + + + + {environment === null + ? `Runs on ${remoteChild.label}. Connect to it to open the thread there.` + : `Open on ${remoteChild.label}`} + + +
  • + ); +} + export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -332,12 +402,17 @@ export function ThreadRelationshipsPanel(props: { { id: "active", label: null, rows: active, expanded: true }, { id: "previous", label: "Previous agents", rows: previous, expanded: false }, ]; + // Tasks running in a linked environment have no thread here; they get their own rows. + const remoteSubagents = (projection?.subagents ?? []).filter( + (agent): agent is OrchestrationV2Subagent & { remoteChild: OrchestrationV2RemoteTaskChild } => + agent.remoteChild !== undefined, + ); const runningCount = (projection?.subagents.filter( (agent) => agent.childThreadId === null && agent.status === "running", ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length; - if (relationshipRows.length === 0 && runningCount === 0) { + if (relationshipRows.length === 0 && runningCount === 0 && remoteSubagents.length === 0) { return null; } @@ -700,6 +775,20 @@ export function ThreadRelationshipsPanel(props: { } ))} + {remoteSubagents.length > 0 ? ( + {}}> + {remoteSubagents.map((agent) => ( + entry.instanceId === agent.providerInstanceId)} + /> + ))} + + ) : null} ); } diff --git a/apps/web/src/components/chat/ThreadServersPanel.tsx b/apps/web/src/components/chat/ThreadServersPanel.tsx new file mode 100644 index 0000000000000..9b7be42eccef7 --- /dev/null +++ b/apps/web/src/components/chat/ThreadServersPanel.tsx @@ -0,0 +1,155 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + AuthPreviewOperateScope, + type DiscoveredLocalServer, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { GlobeIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "../../lib/utils"; +import { useThreadDiscoveredPorts } from "../../portDiscoveryState"; +import { useEnvironment } from "../../state/environments"; +import { previewEnvironment } from "../../state/preview"; +import { useEnvironmentScope } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { openDiscoveredPort } from "../preview/openDiscoveredPort"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ThreadDetailsControl } from "./ThreadDetailsControl"; +import { ThreadDetailsSection } from "./ThreadDetailsSection"; +import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./threadDetailsPanelStyles"; + +const serverKey = (server: DiscoveredLocalServer) => `${server.pid}:${server.port}`; + +/** Web servers the thread's terminals and agent started. Hidden while none run. */ +export function ThreadServersPanel(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const servers = useThreadDiscoveredPorts(props); + const canOpen = useEnvironmentScope(props.environmentId, AuthPreviewOperateScope); + const canStop = useAtomValue( + previewEnvironment.stopLocalServer.permissionAtom(props.environmentId), + ); + const supportsStop = + useEnvironment(props.environmentId)?.serverConfig?.environment.capabilities.localServerStop === + true; + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const stopServer = useAtomCommand(previewEnvironment.stopLocalServer, { + label: "thread server stop", + }); + const [stoppingKey, setStoppingKey] = useState(null); + + if (servers.length === 0) return null; + const threadRef = scopeThreadRef(props.environmentId, props.threadId); + + const reportFailure = (title: string, error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : String(error), + }), + ); + }; + + const open = async (server: DiscoveredLocalServer) => { + const result = await openDiscoveredPort({ threadRef, port: server, openPreview }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportFailure("Unable to open preview", squashAtomCommandFailure(result)); + } + }; + + const stop = async (server: DiscoveredLocalServer) => { + if (server.pid === null || stoppingKey !== null) return; + setStoppingKey(serverKey(server)); + const result = await stopServer({ + environmentId: props.environmentId, + input: { pid: server.pid, port: server.port }, + }); + setStoppingKey(null); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportFailure("Could not stop server", squashAtomCommandFailure(result)); + } + }; + + return ( + +
      + {servers.map((server) => { + const key = serverKey(server); + const stoppable = canStop && supportsStop && server.pid !== null; + return ( +
    • + + void open(server)} + /> + } + > + + + localhost:{server.port} + + {/* The stop button covers this while it shows. */} + + {[server.terminal === null ? "Agent" : "Terminal", server.processName] + .filter(Boolean) + .join(" · ")} + + + Open in the browser panel + + {stoppable ? ( +
      + + void stop(server)} + /> + } + > + {stoppingKey === key ? ( + + ) : ( + + )} + + Stop server + +
      + ) : null} +
    • + ); + })} +
    +
    + ); +} diff --git a/apps/web/src/components/chat/ThreadTimeline.tsx b/apps/web/src/components/chat/ThreadTimeline.tsx index 6a9d372e951a4..7d1db86178cf4 100644 --- a/apps/web/src/components/chat/ThreadTimeline.tsx +++ b/apps/web/src/components/chat/ThreadTimeline.tsx @@ -46,6 +46,7 @@ export function ThreadTimeline({ nextEntries: entries, rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- stores the committed ready timeline in the module-level cache used during thread switches useLayoutEffect(() => { if (detailLoading || entries.length === 0 || timelineHasEphemeralPreviewUrls(entries)) { return; diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 203fb34d2dbda..0c459717e48cd 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -1,7 +1,13 @@ import { InlineAudioPreview } from "../files/InlineAudioPreview"; import { filePreviewKind } from "@t3tools/shared/filePreview"; import { findRevealRef } from "./markdownFindContext"; -import { type MessageId, type ServerProviderSkill, ThreadId } from "@t3tools/contracts"; +import { + type EnvironmentId, + type MessageId, + type ScheduledTaskId, + type ServerProviderSkill, + ThreadId, +} from "@t3tools/contracts"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { Link } from "@tanstack/react-router"; import { useQuietThreadShell } from "../../state/entities"; @@ -75,6 +81,9 @@ import { import { UserMessageContextReferenceChip } from "./UserMessageContextChips"; import { resolvePreviewAnnotationImage } from "./TimelineUserMessageRow.logic"; +const BUBBLE_CHIP_CLASS_NAME = + "[&_[data-slot=context-chip]]:[--context-chip-ink:var(--context-chip-message,var(--context-chip-accent))]"; + function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { const ctx = use(TimelineRowCtx); // Row updates rebuild attachment objects, so the asset follows only the @@ -140,16 +149,10 @@ function AutomationMessageRow({ row }: { row: Extract setExpanded(true)); const { text, scheduledTaskId } = resolveUserMessagePresentation(row.message); - const tasksQuery = useEnvironmentQuery( - scheduledTaskId - ? serverEnvironment.scheduledTasksLive({ - environmentId: ctx.activeThreadEnvironmentId, - input: {}, - }) - : null, - ); - const taskTitle = tasksQuery.data?.tasks.find((task) => task.id === scheduledTaskId)?.title; - const summary = taskTitle ?? text.trim().split(/\r?\n/, 1)[0] ?? ""; + const summary = + useScheduledTaskTitle(ctx.activeThreadEnvironmentId, scheduledTaskId) ?? + text.trim().split(/\r?\n/, 1)[0] ?? + ""; const revertTurnCount = row.revertTurnCount; const promptLink = promptLinkFor(text); @@ -165,6 +168,7 @@ function AutomationMessageRow({ row }: { row: Extract @@ -227,7 +231,17 @@ function AutomationMessageRow({ row }: { row: Extract task.id === scheduledTaskId)?.title; +} + +export function FoldedMessageHeader(props: { icon: LucideIcon; label: string; summary: string; @@ -454,6 +468,7 @@ function UserTimelineRow({ row }: { row: Extract ); } + if (item.type === "link_request") { + return ( + + ); + } if (isV2LifecycleItem(item)) { return ( { const element = listRef.current?.getScrollableNode(); if (!element) return; @@ -225,6 +226,7 @@ function ExpandedWorkGroupEntries({ setExpandedContentHeight(height); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads expanded row sizes from LegendList's measured state after layout useLayoutEffect(updateExpandedContentHeight, [entries, updateExpandedContentHeight]); return ( diff --git a/apps/web/src/components/chat/TriageDraftHero.tsx b/apps/web/src/components/chat/TriageDraftHero.tsx new file mode 100644 index 0000000000000..c8d3d8528df35 --- /dev/null +++ b/apps/web/src/components/chat/TriageDraftHero.tsx @@ -0,0 +1,59 @@ +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { TRIAGE_CLI_COMMAND } from "~/hooks/useStartTriage"; + +import { InlineButton } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +/** + * The empty-draft hero for a triage session, in place of "What should we work on?". + * Keeps the regular hero's heading-plus-one-line rhythm above the composer. + */ +export function TriageDraftHero(props: { + readonly machineLabel: string | null; + readonly serverVersion: string | null; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 1500, target: "command" }); + return ( +
    +

    + What went wrong? +

    +

    + + } + > + {props.machineLabel ?? "This machine"} + {props.serverVersion === null ? null : ( + + v{props.serverVersion} + + )} + + + The agent reads T3 Code's logs and source here, and asks before it applies a fix or + posts anything. + + + + · + + + copyToClipboard(TRIAGE_CLI_COMMAND, undefined)} + /> + } + > + {isCopied ? "Copied" : "or run"} + {isCopied ? null : {TRIAGE_CLI_COMMAND}} + + Copy command, for a terminal + +

    +
    + ); +} diff --git a/apps/web/src/components/chat/UserMessageContextChips.tsx b/apps/web/src/components/chat/UserMessageContextChips.tsx index b1179a83c7725..d5f2accf43b5e 100644 --- a/apps/web/src/components/chat/UserMessageContextChips.tsx +++ b/apps/web/src/components/chat/UserMessageContextChips.tsx @@ -1,4 +1,4 @@ -import { documentIconColorForFileName } from "~/pierre-icons"; +import { documentChipKindForFileName } from "~/pierre-icons"; import { isSvgAttachment } from "@t3tools/shared/image"; import { useRenderedAppearance } from "../../hooks/useTheme"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; @@ -6,7 +6,7 @@ import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { COMPOSER_CONTEXT_KINDS } from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { use, type CSSProperties, type ReactNode } from "react"; +import { use, type ReactNode } from "react"; import { FileDiff } from "@pierre/diffs/react"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { @@ -22,6 +22,7 @@ import { } from "../../lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown from "../ChatMarkdown"; +import { EnvironmentContextChip } from "../EnvironmentContextChip"; import { ThreadContextChip } from "../ThreadContextChip"; import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon } from "lucide-react"; import type { ComposerContextId, KnownComposerContextRecord } from "@t3tools/contracts"; @@ -65,14 +66,12 @@ function UserMessageMentionChip(props: { copyMarkdown: string; }) { const ctx = use(TimelineRowCtx); - const accent = documentIconColorForFileName(props.record.path); return ( } aria-label={`Preview ${props.record.path}`} data-markdown-copy={props.copyMarkdown} @@ -329,6 +328,16 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry ), }, + { + kind: "environment", + canRender: (record) => record.kind === "environment", + render: (record, context) => + record.kind === "environment" ? ( + + ) : ( + + ), + }, { kind: "image", canRender: (record, context) => diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 2562601140870..96387b59df201 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -3,6 +3,9 @@ import { AgentElapsed } from "./AgentElapsed"; import { projectedSubagentsToRuntime } from "@t3tools/client-runtime/state/subagentRuntime"; import type { ReactNode } from "react"; import { useThreadShell, useProject } from "../../state/entities"; +import { useEnvironment } from "../../state/environments"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { useNavigate } from "@tanstack/react-router"; import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef, scopeProjectRef } from "@t3tools/client-runtime/environment"; @@ -21,6 +24,7 @@ import { ProviderDriverKind, type OrchestrationV2Notification, type OrchestrationV2TurnItem, + type OrchestrationV2RemoteTaskChild, type OrchestrationV2Subagent, type ProviderInstanceId, type ServerProvider, @@ -199,6 +203,7 @@ export function V2LifecycleRow(props: { startedAt={item.startedAt} completedAt={item.completedAt} threadId={item.childThreadId} + remote={item.remoteChild} onOpenThread={props.onOpenThread} /> ); @@ -398,6 +403,8 @@ function SubagentTimelineLink(props: { readonly startedAt: DateTime.Utc | null; readonly completedAt: DateTime.Utc | null; readonly threadId: ThreadId | null; + /** The thread in a linked environment that runs this task, when it runs there. */ + readonly remote?: OrchestrationV2RemoteTaskChild | undefined; readonly onOpenThread: (threadId: ThreadId) => void; readonly event?: { readonly status: OrchestrationV2TurnItem["status"] | null; @@ -409,7 +416,21 @@ function SubagentTimelineLink(props: { environmentThreadDetails.threadAtom(props.parentRef), (thread) => thread?.projection.subagents.find((agent) => agent.id === props.subagentId) ?? null, ); + const remote = props.remote ?? agent?.remoteChild; + const remoteEnvironment = useEnvironment(remote?.environmentId ?? null); + const navigate = useNavigate(); const threadId = props.threadId; + // A task in a linked environment opens there, if this client is connected to it. + const open = + threadId !== null + ? () => props.onOpenThread(threadId) + : remote !== undefined && remoteEnvironment !== null + ? () => + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(remote.environmentId, remote.threadId)), + }) + : null; const liveStatus = agent?.status ?? props.status; const status = props.event ? props.event.status : liveStatus; const statusLabel = props.event?.label ?? subagentStatusVisual(liveStatus).label; @@ -434,7 +455,14 @@ function SubagentTimelineLink(props: { status={status ?? undefined} /> - {props.title} + + + {props.title} + + {remote !== undefined ? ( + on {remote.label} + ) : null} + {props.event ? props.event.timestamp : } - {threadId !== null ? ( + {open !== null ? ( {content}
    @@ -485,7 +513,7 @@ function SubagentTimelineLink(props: { data-v2-item-type="subagent" aria-label={`Open ${props.title}`} aria-description={statusLabel} - onClick={() => props.onOpenThread(threadId)} + onClick={open} className={cn( className, "cursor-pointer transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", diff --git a/apps/web/src/components/chat/WorkLog.tsx b/apps/web/src/components/chat/WorkLog.tsx index 90a42a68f0ced..006e08f04f66b 100644 --- a/apps/web/src/components/chat/WorkLog.tsx +++ b/apps/web/src/components/chat/WorkLog.tsx @@ -139,6 +139,7 @@ export function WorkLogDetails({ }) { const scrollerRef = useRef(null); const atLiveEdgeRef = useRef(true); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pins the scroller's scrollTop to the bottom after each commit while following live output useLayoutEffect(() => { const scroller = scrollerRef.current; if (followLive && atLiveEdgeRef.current && scroller) { diff --git a/apps/web/src/components/chat/chatViewSend.ts b/apps/web/src/components/chat/chatViewSend.ts index 9755311a9a68b..81a00823a98be 100644 --- a/apps/web/src/components/chat/chatViewSend.ts +++ b/apps/web/src/components/chat/chatViewSend.ts @@ -269,6 +269,7 @@ export function restoreComposerDraft( store.setPreviewAnnotations(draftTarget, snapshot.previewAnnotations); store.setReviewComments(draftTarget, snapshot.reviewComments); store.setThreadContexts(draftTarget, snapshot.threadContexts); + store.setEnvironmentContexts(draftTarget, snapshot.environmentContexts); return images; } diff --git a/apps/web/src/components/chat/composerContextUndo.ts b/apps/web/src/components/chat/composerContextUndo.ts index 9fc27ed079c88..fd2a0cde59341 100644 --- a/apps/web/src/components/chat/composerContextUndo.ts +++ b/apps/web/src/components/chat/composerContextUndo.ts @@ -1,4 +1,8 @@ -import type { PreviewAnnotationPayload, ThreadContextRecord } from "@t3tools/contracts"; +import type { + EnvironmentContextRecord, + PreviewAnnotationPayload, + ThreadContextRecord, +} from "@t3tools/contracts"; import { useEffect, useState } from "react"; import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; @@ -20,6 +24,7 @@ export interface RetainedContextPayloads extends RetainedAttachmentContextPayloa terminals: Map; reviewComments: Map; threads: Map; + environments: Map; } function createRetainedContextPayloads(): RetainedContextPayloads { @@ -27,6 +32,7 @@ function createRetainedContextPayloads(): RetainedContextPayloads { terminals: new Map(), reviewComments: new Map(), threads: new Map(), + environments: new Map(), files: new Map(), previewAnnotations: new Map(), }; @@ -42,6 +48,7 @@ export function useRetainedContextPayloads(draftTargetKey: string): RetainedCont setDraft({ targetKey: draftTargetKey, payloads: createRetainedContextPayloads() }); } const retained = draft.payloads; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the retained payload maps when the draft target changes or the composer unmounts useEffect( () => () => { for (const payloads of Object.values(retained)) payloads.clear(); diff --git a/apps/web/src/components/chat/threadContextDrag.ts b/apps/web/src/components/chat/threadContextDrag.ts index 040e5a920e25d..66e89060c231b 100644 --- a/apps/web/src/components/chat/threadContextDrag.ts +++ b/apps/web/src/components/chat/threadContextDrag.ts @@ -10,6 +10,7 @@ export function useThreadContextDrop( onDrop: (refs: ReadonlyArray, isActive: () => boolean) => void, ) { const handleDrop = useEffectEvent(onDrop); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes the target element to thread-context drop events and marks old drops inactive useEffect(() => { const target = targetRef.current; if (!target) return; diff --git a/apps/web/src/components/chat/threadFindHighlights.ts b/apps/web/src/components/chat/threadFindHighlights.ts index 55d0647c9a8e9..65d3b0bb6339f 100644 --- a/apps/web/src/components/chat/threadFindHighlights.ts +++ b/apps/web/src/components/chat/threadFindHighlights.ts @@ -122,6 +122,7 @@ export function useThreadFindHighlights(input: { // The observer repaints with whichever match is selected when text changes. const readSelection = useEffectEvent(() => ({ activeRowId, activeOccurrence, onActiveRange })); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- paints CSS Highlight ranges for the query, repainting on DOM mutations, and clears them on cleanup useEffect(() => { if (typeof CSS === "undefined" || !CSS.highlights || typeof Highlight === "undefined") { readSelection().onActiveRange(null); @@ -208,6 +209,7 @@ export function useThreadFindHighlights(input: { // onActiveRange is read at paint time: its identity follows the caller's // layout inputs, and repainting the active highlight for those is wasted work. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- repaints the active CSS Highlight range when the selected match changes useEffect(() => { paintRef.current?.(rangesRef.current, { activeRowId, diff --git a/apps/web/src/components/chat/useAssistantCitationTarget.ts b/apps/web/src/components/chat/useAssistantCitationTarget.ts index 0209549183d5a..6eebd70171bdf 100644 --- a/apps/web/src/components/chat/useAssistantCitationTarget.ts +++ b/apps/web/src/components/chat/useAssistantCitationTarget.ts @@ -122,6 +122,7 @@ export function useAssistantCitationTarget({ // in a cleanup: StrictMode and replay cleanups on a live activation. // Unmounting needs no retire; the rows observing it unmount with the list. const activatedRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- retires the replaced citation scroll and activates the new one, releasing the timeline follow useEffect(() => { if (activatedRef.current === current) return; activatedRef.current?.retire(); @@ -130,6 +131,7 @@ export function useAssistantCitationTarget({ }, [current]); // Escape and pointerdown dismiss before the viewport mounts; the viewport // listeners join once it exists. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes document and viewport listeners that dismiss the pending citation scroll useEffect(() => current?.attachDismissal(viewport), [current, viewport]); const settled = current !== null && settlement?.target === current.target ? settlement : null; diff --git a/apps/web/src/components/chat/useAttachmentPreviewHandoff.ts b/apps/web/src/components/chat/useAttachmentPreviewHandoff.ts index c42047f2f4843..7776151890e19 100644 --- a/apps/web/src/components/chat/useAttachmentPreviewHandoff.ts +++ b/apps/web/src/components/chat/useAttachmentPreviewHandoff.ts @@ -37,6 +37,7 @@ export function useAttachmentPreviewHandoff(input: AttachmentPreviewHandoffInput const handoffByMessageId = input.handoff; // Blob URLs are a browser resource owned per thread; leaving the thread or the view ends them. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- revokes the thread's blob preview URLs when the thread changes or the view unmounts useEffect(() => () => blobPreviews.revokeThread(threadId), [blobPreviews, threadId]); const serverAttachmentResources = selectHandoffImageResources( diff --git a/apps/web/src/components/chat/useChatShortcuts.ts b/apps/web/src/components/chat/useChatShortcuts.ts index e656bf5a79a4a..17bce24b1020c 100644 --- a/apps/web/src/components/chat/useChatShortcuts.ts +++ b/apps/web/src/components/chat/useChatShortcuts.ts @@ -102,6 +102,7 @@ export function useChatShortcuts(input: ChatShortcutsInput) { } if (input.enabled && shouldEscapeFocusComposer(event)) input.composerRef.current?.focusAtEnd(); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches window keydown listeners for chat shortcuts and find dismissal for the component's lifetime useEffect(() => { const onKeyDownCapture = (event: KeyboardEvent) => handleShortcut(event); const onKeyDown = (event: KeyboardEvent) => handleEscape(event); diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index 5b560b0601726..566876628449d 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -115,6 +115,9 @@ export function useComposerContextImport(input: { (store) => store.addPreviewAnnotation, ); const addComposerDraftThreadContexts = useComposerDraftStore((store) => store.addThreadContexts); + const addComposerDraftEnvironmentContexts = useComposerDraftStore( + (store) => store.addEnvironmentContexts, + ); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false }); const loadThreadTranscript = useAtomCommand(orchestrationEnvironment.threadTranscript, { reportFailure: false, @@ -315,7 +318,7 @@ export function useComposerContextImport(input: { ? existing.record.id : undefined, }) - : existing?.kind === "thread" + : existing?.kind === "thread" || existing?.kind === "environment" ? existing.record : existing ? (uploadedContextRecordFromDraft(existing) ?? undefined) @@ -377,6 +380,16 @@ export function useComposerContextImport(input: { rewritten.set(record.contextId, record.contextId); break; } + case "environment": { + // A machine means the same machine from any thread, so a pasted one is kept, + // but not in an answer to an agent's question, which carries no machines. + if (questionAttachmentTarget !== null) break; + addComposerDraftEnvironmentContexts(composerDraftTarget, [record], { + appendReference: false, + }); + rewritten.set(record.contextId, record.contextId); + break; + } case "image": case "file": { if (skippedDependentAttachmentIds.has(record.contextId)) break; diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 0fc18e77a6275..26486cc266b39 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -1,4 +1,8 @@ -import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; +import { + matchComposerEnvironmentItems, + matchComposerThreadItems, +} from "@t3tools/client-runtime/composerThreadItems"; +import { useAtomValue } from "@effect/atom-react"; import { useState } from "react"; import type { EnvironmentId, @@ -16,6 +20,7 @@ import { import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import type { ComposerTrigger } from "../../composer-logic"; +import type { DraftId } from "../../composerDraftStore"; import { basenameOfPath } from "../../pierre-icons"; import { getProviderSkillsForSlashMenu, @@ -23,6 +28,8 @@ import { } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useThreadShells } from "~/state/entities"; +import { useEnvironmentIdentities, useEnvironmentMachines } from "~/state/environments"; +import { offersLinkedEnvironmentsAtom } from "~/state/peerLinks"; import { pullRequestEnvironment, usePullRequestList, @@ -60,6 +67,7 @@ export function useComposerMenuItems({ selectedProviderSkills, selectedProviderSlashCommands, showSkillsInSlashMenu, + questionAttachmentTarget, }: { readonly composerTrigger: ComposerTrigger | null; readonly environmentId: EnvironmentId; @@ -74,6 +82,7 @@ export function useComposerMenuItems({ readonly selectedProviderSkills: ServerProvider["skills"]; readonly selectedProviderSlashCommands: ServerProvider["slashCommands"]; readonly showSkillsInSlashMenu: boolean; + readonly questionAttachmentTarget: DraftId | null; }) { // Thread mentions rank by recency as of when the composer mounted. const [now] = useState(() => Date.now()); @@ -94,6 +103,14 @@ export function useComposerMenuItems({ const environmentThreadShells = useThreadShells(isPathTrigger); // Mounts older settled threads matching the mention, which shells may leave out. useThreadSearch(isPathTrigger ? [environmentId] : [], pathTriggerQuery, { titlesOnly: true }); + // The user's machines feed `@` machine matches; the catalog summary is cheap and stable. + const environmentIdentities = useEnvironmentIdentities(); + const environmentMachines = useEnvironmentMachines(); + const offersMachines = useAtomValue(offersLinkedEnvironmentsAtom(environmentId)); + const mentionableEnvironments = environmentIdentities.map((identity) => ({ + ...identity, + machine: environmentMachines.get(identity.environmentId) ?? ("server" as const), + })); const workspaceEntries = useComposerPathSearch({ environmentId, cwd: isPathTrigger ? gitCwd : null, @@ -169,6 +186,14 @@ export function useComposerMenuItems({ query: composerTrigger.query, now, }), + // An answer to an agent's question carries thread links, not machines. + ...(questionAttachmentTarget === null && offersMachines + ? matchComposerEnvironmentItems({ + environments: mentionableEnvironments, + environmentId, + query: composerTrigger.query, + }) + : []), ...workspaceEntries.entries.map((entry) => ({ id: `path:${entry.kind}:${entry.path}`, type: "path" as const, diff --git a/apps/web/src/components/chat/useComposerRestingTransition.ts b/apps/web/src/components/chat/useComposerRestingTransition.ts index 64b18b6778019..055fdfe263ac2 100644 --- a/apps/web/src/components/chat/useComposerRestingTransition.ts +++ b/apps/web/src/components/chat/useComposerRestingTransition.ts @@ -90,6 +90,7 @@ export function useComposerRestingTransition( clearOverlayPin(); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mirrors the collapsed flag into a ref read by ResizeObserver callbacks and running animations useLayoutEffect(() => { isCollapsedRef.current = isCollapsed; }); @@ -434,6 +435,7 @@ export function useComposerRestingTransition( }; }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- runs the FLIP animation against committed DOM geometry before paint when the collapsed state changes useLayoutEffect(() => { const requestId = transitionLayoutRequestRef.current + 1; transitionLayoutRequestRef.current = requestId; @@ -452,6 +454,7 @@ export function useComposerRestingTransition( }; }, [isCollapsed, transitionToCurrentGeometry]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the committed overlay element and reports its height when the resting state changes useLayoutEffect(() => { if (previousRestingRef.current === isResting) return; previousRestingRef.current = isResting; @@ -460,6 +463,7 @@ export function useComposerRestingTransition( if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height); }, [isResting, onOverlayHeightChange]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes composer and body resize to record geometry and retarget a running animation useLayoutEffect(() => { const element = elementRef.current; if (!element) return; @@ -504,6 +508,7 @@ export function useComposerRestingTransition( }); }, [restingControlsRef, transitionToCurrentGeometry]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the cleanup timer, cancels Web Animations and strips inline transition styles on unmount useEffect(() => { hasCompletedInitialLayoutRef.current = true; return () => { diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index c63a6a3f4fce1..ba667c4857bef 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -344,6 +344,9 @@ export function useComposerSend(input: ComposerSendInput) { ); const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); + const setComposerDraftEnvironmentContexts = useComposerDraftStore( + (store) => store.setEnvironmentContexts, + ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const sendToMultipleModels = useMultipleModelSend({ environmentId, @@ -383,6 +386,7 @@ export function useComposerSend(input: ComposerSendInput) { // A send awaits uploads before it declines, so it reads the approvals pending by then. const pendingApprovalsRef = useRef(pendingApprovals); const respondingRequestIdsRef = useRef(respondingRequestIds); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mirrors approvals into refs that an async send reads after awaiting uploads useLayoutEffect(() => { pendingApprovalsRef.current = pendingApprovals; respondingRequestIdsRef.current = respondingRequestIds; @@ -521,6 +525,7 @@ export function useComposerSend(input: ComposerSendInput) { previewAnnotations: sendContextPreviewAnnotations, reviewComments: composerReviewComments, threadContexts: composerThreadContexts, + environmentContexts: composerEnvironmentContexts, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -573,6 +578,7 @@ export function useComposerSend(input: ComposerSendInput) { reviewComments: composerReviewComments, previewAnnotations: composerPreviewAnnotations, threadContexts: composerThreadContexts, + environmentContexts: composerEnvironmentContexts, }, setThreadError, ); @@ -590,7 +596,8 @@ export function useComposerSend(input: ComposerSendInput) { elementContextCount: composerPreviewAnnotations.length + composerReviewComments.length + - composerThreadContexts.length, + composerThreadContexts.length + + composerEnvironmentContexts.length, }); const feedbackCommand = ctxSelectedProvider === "codex" && @@ -599,7 +606,8 @@ export function useComposerSend(input: ComposerSendInput) { sendableComposerTerminalContexts.length === 0 && composerPreviewAnnotations.length === 0 && composerReviewComments.length === 0 && - composerThreadContexts.length === 0 + composerThreadContexts.length === 0 && + composerEnvironmentContexts.length === 0 ? parseCodexFeedbackCommand(trimmed) : null; if (feedbackCommand && multipleModelSelections === null) { @@ -675,6 +683,7 @@ export function useComposerSend(input: ComposerSendInput) { const followUpReviewComments = [...composerReviewComments]; const followUpPreviewAnnotations = [...composerPreviewAnnotations]; const followUpThreadContexts = [...composerThreadContexts]; + const followUpEnvironmentContexts = [...composerEnvironmentContexts]; promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -685,6 +694,7 @@ export function useComposerSend(input: ComposerSendInput) { reviewComments: composerReviewComments, previewAnnotations: composerPreviewAnnotations, threadContexts: composerThreadContexts, + environmentContexts: composerEnvironmentContexts, }), interactionMode: followUp.interactionMode, }); @@ -698,6 +708,7 @@ export function useComposerSend(input: ComposerSendInput) { reviewComments: followUpReviewComments, previewAnnotations: followUpPreviewAnnotations, threadContexts: followUpThreadContexts, + environmentContexts: followUpEnvironmentContexts, }, writePrompt: (prompt) => setComposerDraftPrompt(composerDraftTarget, prompt), writeTerminalContexts: (contexts) => @@ -708,6 +719,8 @@ export function useComposerSend(input: ComposerSendInput) { setComposerDraftPreviewAnnotations(composerDraftTarget, [...annotations]), writeThreadContexts: (records) => setComposerDraftThreadContexts(composerDraftTarget, [...records]), + writeEnvironmentContexts: (records) => + setComposerDraftEnvironmentContexts(composerDraftTarget, [...records]), resetCursor: (options) => composerRef.current?.resetCursorState(options), }); } else if ( @@ -725,7 +738,8 @@ export function useComposerSend(input: ComposerSendInput) { sendableComposerTerminalContexts.length === 0 && composerPreviewAnnotations.length === 0 && composerReviewComments.length === 0 && - composerThreadContexts.length === 0 + composerThreadContexts.length === 0 && + composerEnvironmentContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand && multipleModelSelections === null) { @@ -771,6 +785,7 @@ export function useComposerSend(input: ComposerSendInput) { previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, threadContexts: composerThreadContexts, + environmentContexts: composerEnvironmentContexts, }); const { files: composerFilesSnapshot, @@ -779,6 +794,7 @@ export function useComposerSend(input: ComposerSendInput) { previewAnnotations: composerPreviewAnnotationsSnapshot, reviewComments: composerReviewCommentsSnapshot, threadContexts: composerThreadContextsSnapshot, + environmentContexts: composerEnvironmentContextsSnapshot, messageText: messageTextForSend, } = sendSnapshot; const { @@ -813,6 +829,7 @@ export function useComposerSend(input: ComposerSendInput) { reviewComments: composerReviewCommentsSnapshot, previewAnnotations: composerPreviewAnnotationsSnapshot, threadContexts: composerThreadContextsSnapshot, + environmentContexts: composerEnvironmentContextsSnapshot, attachments: composerAttachmentsSnapshot.map((attachment, index) => ({ attachment, attachmentId: attachmentIds[index] ?? attachment.id, diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts index ee7bacf4dbb29..4bd42afd5b6e5 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -694,6 +694,7 @@ export function useComposerStash(input: { ).terminalOpen, ), })); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers the capture-phase stash shortcut with the command registry while the composer is mounted useEffect(() => { if (backgroundThread) return; return registerCommands( diff --git a/apps/web/src/components/chat/useComposerTypingGuard.ts b/apps/web/src/components/chat/useComposerTypingGuard.ts index 22c517f4ed1db..b1770ab3fdb45 100644 --- a/apps/web/src/components/chat/useComposerTypingGuard.ts +++ b/apps/web/src/components/chat/useComposerTypingGuard.ts @@ -39,6 +39,7 @@ export function useComposerTypingGuard(scope: string, requestIds: readonly strin const onFocus = () => dispatch({ type: "focus" }); const onBlur = () => dispatch({ type: "blur" }); const onSend = () => dispatch({ type: "send" }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a timer that releases held requests once typing has been idle long enough useEffect(() => { if (state.held.size === 0) return; const timeout = window.setTimeout( diff --git a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts index 1ef648e48c90d..790b9ce99a57a 100644 --- a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts +++ b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts @@ -111,6 +111,7 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) loadBalancingCandidates, loadBalancingSettings.loadBalancingWeights, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes the resolved load-balanced environment into the draft store once the async pick settles useEffect(() => { if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return; const target = logicalProjectEnvironments.find( @@ -171,10 +172,12 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) readonly scope: string; } | null>(null); const environmentChangeScopeRef = useRef(""); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mirrors the current change scope into a ref that async environment switches compare against useLayoutEffect(() => { environmentChangeScopeRef.current = environmentChangeScope; }); // Leaving the scope, or unmounting, abandons a change still in flight. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- abandons an in-flight environment change when the draft or project scope changes or unmounts useLayoutEffect( () => () => { if (environmentChangeRef.current?.scope === environmentChangeScope) { diff --git a/apps/web/src/components/chat/useDraftHeroLayoutTransition.ts b/apps/web/src/components/chat/useDraftHeroLayoutTransition.ts index a156512a3d37a..adecfc68a4b86 100644 --- a/apps/web/src/components/chat/useDraftHeroLayoutTransition.ts +++ b/apps/web/src/components/chat/useDraftHeroLayoutTransition.ts @@ -19,6 +19,7 @@ export function useDraftHeroLayoutTransition( previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null; }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the committed composer rect and starts a FLIP Web Animation before paint useLayoutEffect(() => { const transitionGroup = transitionGroupRef.current; const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; diff --git a/apps/web/src/components/chat/usePasteToFocusComposer.ts b/apps/web/src/components/chat/usePasteToFocusComposer.ts index 7311b3f4c0edb..7c16110c94dab 100644 --- a/apps/web/src/components/chat/usePasteToFocusComposer.ts +++ b/apps/web/src/components/chat/usePasteToFocusComposer.ts @@ -16,6 +16,7 @@ export function usePasteToFocusComposer( composerRef: ComposerHandleRef, ) { const pasteAsTextShortcutUntilRef = useRef(0); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches window keydown and paste capture listeners that route stray pastes into the composer useEffect(() => { const keyHandler = (event: KeyboardEvent) => { if ( diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 08e919cb152ec..9c9ac38e1cb1e 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -134,6 +134,7 @@ const savedContent = (prompt: string) => ({ reviewComments: [], previewAnnotations: [], threadContexts: [], + environmentContexts: [], }); const promptOf = (ref: ScopedThreadRef) => diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index a57b1f687b7a6..9ea5b4ca573f2 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -7,6 +7,7 @@ import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type ChatAttachment, + type EnvironmentContextRecord, type EnvironmentId, type MessageId, type OrchestrationMessageContext, @@ -76,6 +77,7 @@ export interface QueuedRunEditContent { readonly reviewComments: ReadonlyArray; readonly previewAnnotations: ReadonlyArray; readonly threadContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; } function queuedEditDraftTarget(routeThreadRef: ScopedThreadRef, runId: RunId) { @@ -133,6 +135,7 @@ async function submitQueuedRunEdit(input: { reviewComments: content.reviewComments, previewAnnotations: content.previewAnnotations, threadContexts: content.threadContexts, + environmentContexts: content.environmentContexts, attachments: newAttachments.map((attachment, index) => ({ attachment, attachmentId: uploads[editing.existingAttachments.length + index]?.id ?? attachment.id, diff --git a/apps/web/src/components/chat/useRestingComposerControlsLayout.ts b/apps/web/src/components/chat/useRestingComposerControlsLayout.ts index e75aab3a117fd..882aa14b369af 100644 --- a/apps/web/src/components/chat/useRestingComposerControlsLayout.ts +++ b/apps/web/src/components/chat/useRestingComposerControlsLayout.ts @@ -45,6 +45,7 @@ export function useRestingComposerControlsLayout( }); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes control resize, DOM mutations and font loading to measure which controls fit useLayoutEffect(() => { const currentHost = useControlsAsHost ? controls : host; if (!currentHost || !controls) return; diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index be4c9dd9cbfd5..0e571b3c4a952 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -289,6 +289,7 @@ export function useRightPanelActions(input: RightPanelActionsInput) { onDiffPanelOpen?.(); }; const onOpenTurnDiffStable = useStableCallback(onOpenTurnDiff); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the preview action bus to toggle the preview panel useEffect( () => subscribePreviewAction((action) => { diff --git a/apps/web/src/components/chat/useThreadFind.ts b/apps/web/src/components/chat/useThreadFind.ts index bc66217cd4d26..b88951e3b6ac3 100644 --- a/apps/web/src/components/chat/useThreadFind.ts +++ b/apps/web/src/components/chat/useThreadFind.ts @@ -180,11 +180,13 @@ export function useThreadFind({ })); } }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reruns a live refresh that was coalesced behind a server search once that search settles useEffect(() => { if (!refreshAfterSearchRef.current || remote.isPending) return; refreshAfterSearchRef.current = false; if (!remote.error) refreshLiveResults(); }, [remote.isPending, remote.error]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts a debounce timer to refresh find results when streamed thread content changes useEffect(() => { if (lastContentRef.current === content) return; lastContentRef.current = content; @@ -194,6 +196,7 @@ export function useThreadFind({ refreshLiveResults(); }, 300); }, [content, isOpen, state.query]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the pending refresh timer when the thread, open state or query changes useEffect(() => { if (!isOpen || threadKey === null || !state.query.trim()) return; return () => { diff --git a/apps/web/src/components/chat/useThreadFindNavigation.ts b/apps/web/src/components/chat/useThreadFindNavigation.ts index 4bedf88b02637..054faa23201e0 100644 --- a/apps/web/src/components/chat/useThreadFindNavigation.ts +++ b/apps/web/src/components/chat/useThreadFindNavigation.ts @@ -226,6 +226,7 @@ export function useThreadFindNavigation({ // scroll time; the viewport ref callback in MessagesTimeline cannot read // them without a latest-ref mirror, and capturing them would re-attach the // viewport (observers, citation dismissal) on every streamed entry. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches a scroll listener to the timeline container to track the active match position useEffect(() => { if (!container) return; const onScroll = () => { @@ -250,6 +251,7 @@ export function useThreadFindNavigation({ key: null, pages: new Set(), }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- requests earlier history pages until the active match's entry is loaded useEffect(() => { if (requestedPagesRef.current.key !== matchKey) requestedPagesRef.current = { key: matchKey, pages: new Set() }; @@ -282,6 +284,7 @@ export function useThreadFindNavigation({ // Positioning also depends on the list: a loaded page or a shifted row must // retry the jump for the same selection. Declared after the highlight // effects so it reuses the range they just resolved. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the list to the active match after commit when rows or the loaded page change useEffect(() => { reveal(stateRef.current.activeRange, { matchKey, diff --git a/apps/web/src/components/chat/useThreadFindPositionReader.ts b/apps/web/src/components/chat/useThreadFindPositionReader.ts index 6568db9d04dfe..79445a1afaf3d 100644 --- a/apps/web/src/components/chat/useThreadFindPositionReader.ts +++ b/apps/web/src/components/chat/useThreadFindPositionReader.ts @@ -37,6 +37,7 @@ export function useThreadFindPositionReader({ readonly contentInsetEndAdjustment: number; }) { // Layout effect so a find opened right after a thread switch reads this viewport. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches the timeline viewport's position reader to the thread find registry useLayoutEffect(() => { if (!timelineViewportElement) return; const read: ThreadFindPositionReader = (query) => { diff --git a/apps/web/src/components/chat/useTimelineFollow.ts b/apps/web/src/components/chat/useTimelineFollow.ts index c664f251abcd3..c8ebbbf06cf37 100644 --- a/apps/web/src/components/chat/useTimelineFollow.ts +++ b/apps/web/src/components/chat/useTimelineFollow.ts @@ -358,7 +358,7 @@ export function useTimelineFollow(input: TimelineFollowInput) { // The list does not follow footer layout, so the resting composer cannot scroll it. Growth // above the composer (a new notice) would cover the last rows instead. const followedInsetRef = useRef(composerTimelineInset); - // oxlint-disable-next-line t3code/react-effect-budget -- scrolls the list after its bottom inset grows + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the list after its bottom inset grows useLayoutEffect(() => { const grew = composerTimelineInset > followedInsetRef.current; followedInsetRef.current = composerTimelineInset; @@ -552,6 +552,7 @@ export function useTimelineFollow(input: TimelineFollowInput) { // the anchored end space; the trigger is streamed thread data. A // totalSize listener cannot replace it: the anchored end space can already // be 0, so dropping it changes no size and no list event fires. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the timeline to the end after the commit that drops anchored end space useLayoutEffect(() => { if (timelineAnchorMessageId === null || !timelineLiveFollowEnabled) return; if ( diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index e75ce33ceaf32..fa7ea590ac635 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -28,7 +28,8 @@ function ConfiguredT3ConnectSidebarAvatar() { appearance={{ elements: { avatarBox: "size-7", - userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-row-hover", + // Clerk's unlayered trigger reset (padding, radius, background) beats layered utilities. + userButtonTrigger: "mb-1! rounded-lg! p-1! hover:bg-sidebar-row-hover!", }, }} > diff --git a/apps/web/src/components/clerk/clerkAppearance.ts b/apps/web/src/components/clerk/clerkAppearance.ts index ff1f4d4d4ca0b..31b22995faf0b 100644 --- a/apps/web/src/components/clerk/clerkAppearance.ts +++ b/apps/web/src/components/clerk/clerkAppearance.ts @@ -1,7 +1,17 @@ import type { ClerkProviderProps } from "@clerk/react"; +// Scoped to auth flows: UserProfile reuses formButtonPrimary for destructive confirmations. +const authFlowElements = { + formButtonPrimary: + "border-primary bg-primary bg-none text-primary-foreground shadow-xs shadow-primary/24 hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", +}; + export const clerkAppearance = { + // index.css orders this layer below components and utilities so the element classes below win. + cssLayerName: "clerk", + options: { shimmer: false }, variables: { + borderRadius: "0.5rem", // Clerk reuses its primary color for filled buttons and bare links. colorPrimary: "var(--update-foreground)", colorPrimaryForeground: "var(--card)", @@ -19,10 +29,20 @@ export const clerkAppearance = { colorRing: "var(--ring)", }, elements: { + modalBackdrop: "dialog-backdrop", + cardBox: "dialog-glass rounded-2xl border", + card: "border-0 bg-transparent shadow-none", + footer: "bg-transparent bg-none", + header: "items-start text-start", + headerTitle: "text-xl font-semibold", + headerSubtitle: "text-sm text-muted-foreground", + buttonArrowIcon: "hidden", formFieldErrorText: { color: "var(--error-foreground)" }, formFieldWarningText: { color: "var(--warning-foreground)" }, formFieldSuccessText: { color: "var(--success-foreground)" }, otpCodeFieldErrorText: { color: "var(--error-foreground)" }, otpCodeFieldSuccessText: { color: "var(--success-foreground)" }, }, + signIn: { elements: authFlowElements }, + signUp: { elements: authFlowElements }, } satisfies NonNullable; diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index ead9de100264f..34dfbacc68727 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -123,6 +123,7 @@ export function CloudEnvironmentConnectRows({ .map((environment) => environment.environmentId), ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fetches relay discovery when its inputs change, unless hidden polling covers it, and reports readiness useEffect(() => { let active = true; if (onDiscoveryReady || !refreshWhileEmpty || document.visibilityState === "visible") { @@ -220,6 +221,7 @@ export function CloudEnvironmentConnectRows({ } }, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- auto-selects newly discovered machines from the discovery store and starts their relay connections useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- Discovery results arrive from an external store and auto-selection starts relay connections, which mark themselves connecting. selectNewComputers(environmentsState.environments); @@ -229,6 +231,7 @@ export function CloudEnvironmentConnectRows({ const shouldRefreshWhileEmpty = refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- polls relay discovery on a timer, focus and visibility change while no machine is found useEffect(() => { if (!shouldRefreshWhileEmpty) return; let timer: ReturnType | undefined; diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index 155b4471247f5..53085ecd50ea6 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -7,32 +7,12 @@ import { connectCliSignInRedirectUrl, } from "../../cloud/connectCliAuth"; import { isElectron } from "../../env"; -import { AuthSurfaceShell } from "../auth/AuthSurfaceShell"; +import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page"; import { resolveClerkSignInProps } from "../clerk/authRedirect"; import { Button } from "../ui/button"; -function ConnectCliAuthMessage({ - eyebrow, - title, - description, -}: { - readonly eyebrow?: string; - readonly title: string; - readonly description: string; -}) { - return ( - <> - {eyebrow ? ( -

    {eyebrow}

    - ) : null} -

    {title}

    -

    {description}

    - - ); -} - const invalidLinkMessage = { - eyebrow: "Authorization request", + context: "Authorization request", title: "This connect link is incomplete", description: "The link is missing its authorization request. Re-run `t3 connect` in your terminal and open the freshly printed URL.", @@ -58,6 +38,7 @@ export function ConnectCliAuthorizeSurface() { }; const openSignInOnLoad = useEffectEvent(openSignIn); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens Clerk sign-in or navigates the window to the authorize URL once Clerk has loaded useEffect(() => { if (!request || !isLoaded || redirecting.current) { return; @@ -79,16 +60,16 @@ export function ConnectCliAuthorizeSurface() { if (!request) { return ( - - - + + + ); } return ( - - + ) : null} - + ); } diff --git a/apps/web/src/components/composerContextPresentation.tsx b/apps/web/src/components/composerContextPresentation.tsx index c10d53d1fb9b2..27bbd6c2880cf 100644 --- a/apps/web/src/components/composerContextPresentation.tsx +++ b/apps/web/src/components/composerContextPresentation.tsx @@ -1,7 +1,11 @@ import ChatMarkdown from "./ChatMarkdown"; import { isSvgAttachment } from "@t3tools/shared/image"; import { ReadOnlySourcePreview } from "./files/AttachmentFilePreview"; -import type { PreviewAnnotationPayload, ThreadContextRecord } from "@t3tools/contracts"; +import type { + EnvironmentContextRecord, + PreviewAnnotationPayload, + ThreadContextRecord, +} from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { videoMimeType } from "@t3tools/shared/video"; import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react"; @@ -32,6 +36,7 @@ import { import type { TerminalContextDraft } from "~/lib/terminalContext"; import type { ReviewCommentContext } from "~/reviewCommentContext"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; +import { EnvironmentContextChip } from "./EnvironmentContextChip"; import { ThreadContextChip } from "./ThreadContextChip"; import { createContextPresentationRegistry, @@ -59,7 +64,8 @@ export type ComposerDraftContextRecord = } | { kind: "image"; record: ComposerImageAttachment; upload?: AttachmentUploadState | undefined } | { kind: "file"; record: ComposerFileAttachment; upload?: AttachmentUploadState | undefined } - | { kind: "thread"; record: ThreadContextRecord }; + | { kind: "thread"; record: ThreadContextRecord } + | { kind: "environment"; record: EnvironmentContextRecord }; export interface ComposerContextActions { environmentId: EnvironmentId | null; @@ -98,6 +104,7 @@ export function composerContextRecordsFromDraft(input: { reviewComments?: ReadonlyArray; previewAnnotations?: ReadonlyArray; threadContexts?: ReadonlyArray; + environmentContexts?: ReadonlyArray; images?: ReadonlyArray; files?: ReadonlyArray; uploadsByImageId?: Readonly>; @@ -134,6 +141,9 @@ export function composerContextRecordsFromDraft(input: { for (const record of input.threadContexts ?? []) { records.set(record.contextId, { kind: "thread", record }); } + for (const record of input.environmentContexts ?? []) { + records.set(record.contextId, { kind: "environment", record }); + } return records; } @@ -361,7 +371,15 @@ const composerContextPresentationRegistry = createContextPresentationRegistry< ComposerContextRenderContext, ReactElement >({ - requiredKinds: ["image", "file", "terminal", "review-comment", "preview-annotation", "thread"], + requiredKinds: [ + "image", + "file", + "terminal", + "review-comment", + "preview-annotation", + "thread", + "environment", + ], handlers: [ { kind: "terminal", @@ -457,6 +475,16 @@ const composerContextPresentationRegistry = createContextPresentationRegistry< ), }, + { + kind: "environment", + canRender: (entry) => entry.kind === "environment", + render: (entry, context) => + entry.kind === "environment" ? ( + + ) : ( + + ), + }, ], fallback: (_kind, _entry, context) => , }); diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index 9fac67baec247..b510c1275ee84 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -1,15 +1,8 @@ import type { EnvironmentId, PullRequestContextMetadata } from "@t3tools/contracts"; import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react"; -import { - useEffect, - useState, - type ComponentProps, - type CSSProperties, - type MouseEvent, - type ReactNode, -} from "react"; +import { useEffect, useState, type ComponentProps, type MouseEvent, type ReactNode } from "react"; import { useAssetUrlState } from "~/assets/assetUrls"; -import { documentIconColorForFileName } from "~/pierre-icons"; +import { documentChipKindForFileName } from "~/pierre-icons"; import { PULL_REQUEST_STATE_PRESENTATION } from "~/components/pullRequest/pullRequestIcons"; import type { PullRequestContextDisplayState } from "~/lib/composerContextRecords"; @@ -139,32 +132,6 @@ export function PullRequestChip(props: { ); } -function averageImageColor(image: HTMLImageElement): string | undefined { - try { - const canvas = document.createElement("canvas"); - canvas.width = canvas.height = 16; - const context = canvas.getContext("2d"); - if (!context) return; - context.drawImage(image, 0, 0, 16, 16); - const { data } = context.getImageData(0, 0, 16, 16); - let red = 0; - let green = 0; - let blue = 0; - let alpha = 0; - for (let index = 0; index < data.length; index += 4) { - const weight = data[index + 3]!; - red += data[index]! * weight; - green += data[index + 1]! * weight; - blue += data[index + 2]! * weight; - alpha += weight; - } - if (alpha === 0) return; - return `rgb(${Math.round(red / alpha)} ${Math.round(green / alpha)} ${Math.round(blue / alpha)})`; - } catch { - return; - } -} - export function ImageChipButton({ name, previewUrl, @@ -172,7 +139,6 @@ export function ImageChipButton({ suffix, disabled, onPreviewError, - style, ...props }: Omit, "kind" | "render"> & { name: string; @@ -182,31 +148,20 @@ export function ImageChipButton({ disabled?: boolean | undefined; onPreviewError?: (() => void) | undefined; }) { - const [sample, setSample] = useState<{ url: string; color: string | undefined }>(); - const [corsFailedUrl, setCorsFailedUrl] = useState(); - const accent = sample?.url === previewUrl ? sample?.color : undefined; return ( } aria-label={`Image attachment, ${name}, ${size}`} - style={{ ...style, ...(accent ? { "--context-chip-accent": accent } : {}) } as CSSProperties} {...props} > {previewUrl ? ( { - if (corsFailedUrl === previewUrl) onPreviewError?.(); - else setCorsFailedUrl(previewUrl); - }} - onLoad={(event) => - setSample({ url: previewUrl, color: averageImageColor(event.currentTarget) }) - } + className="size-[1em] shrink-0 self-center rounded-sm object-cover" + onError={onPreviewError} /> ) : ( @@ -243,15 +198,13 @@ export function FileChip(props: { }) { if (props.svgPreview && !props.unresolved) return ; const content = ; - const accent = props.isVideo ? undefined : documentIconColorForFileName(props.name); const state = props.unresolved ? ("unresolved" as const) : props.error ? ("invalid" as const) : null; const attributes = { - kind: props.isVideo ? "video" : "file", - style: accent ? ({ "--context-chip-accent": accent } as CSSProperties) : undefined, + kind: props.isVideo ? "video" : (documentChipKindForFileName(props.name) ?? "file"), ...(state ? { state } : {}), "aria-label": [props.accessibleLabel, props.suffix].filter(Boolean).join(", "), "data-markdown-copy": props.copyMarkdown, @@ -299,6 +252,7 @@ function SvgFileChip(props: ComponentProps) { } : null, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates an object URL for the local SVG file and revokes it on cleanup useEffect(() => { const file = preview?.file; if (!file) return; @@ -348,10 +302,13 @@ function FileChipContent(props: { isVideo: boolean; theme: "light" | "dark"; suffix?: string | null; + unresolved?: boolean; }) { return ( <> - {props.isVideo ? ( + {props.unresolved ? ( + + ) : props.isVideo ? ( ) : ( diff --git a/apps/web/src/components/contextPresentationRegistry.ts b/apps/web/src/components/contextPresentationRegistry.ts index c2993c4cb3535..11be93d46d84b 100644 --- a/apps/web/src/components/contextPresentationRegistry.ts +++ b/apps/web/src/components/contextPresentationRegistry.ts @@ -48,6 +48,10 @@ const DEFINITIONS = [ kind: "thread", capabilities: { details: "tooltip", expanded: "none", defaultDraftView: "compact" }, }, + { + kind: "environment", + capabilities: { details: "tooltip", expanded: "none", defaultDraftView: "compact" }, + }, ] as const satisfies ReadonlyArray; function buildDefinitionRegistry( diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx index 0b76660e7f39f..96cb6c10fa400 100644 --- a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -71,6 +71,7 @@ export function DesktopAppActivationCoordinator() { }), ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to desktop activation requests over the bridge and marks the renderer ready useEffect(() => { if (!ready || activation === undefined) return; diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx index ee4ece96af2a8..a2a0f874d6e90 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx @@ -8,6 +8,9 @@ const state = vi.hoisted(() => ({ projects: [] as never[], navigate: vi.fn(async (): Promise => undefined), openScratchProject: vi.fn(async () => null), + confirm: vi.fn(async (_message: string) => true), + connectPairing: vi.fn(async (_input: { pairingUrl: string }) => ({ _tag: "Success" })), + localEnvironmentDisabled: false, })); // One connected primary environment that can read pull requests. Its project list and @@ -20,11 +23,17 @@ vi.mock(import("@tanstack/react-router"), async (original) => ({ vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), usePrimaryEnvironmentId: () => "local" as EnvironmentId, - usePrimaryEnvironment: (() => ({ - environmentId: "local", - connection: { phase: "connected" }, - serverConfig: {}, - })) as never, + usePrimaryEnvironment: (() => + state.localEnvironmentDisabled + ? null + : { + environmentId: "local", + connection: { phase: "connected" }, + serverConfig: {}, + }) as never, +})); +vi.mock(import("../../localEnvironment"), () => ({ + isLocalEnvironmentDisabled: () => state.localEnvironmentDisabled, })); vi.mock(import("../../state/server"), async (original) => { const { Atom } = await import("effect/reactivity"); @@ -52,6 +61,25 @@ vi.mock(import("../../hooks/useScratchProject"), async (original) => ({ })) as never, })); +vi.mock(import("../../confirmDialog"), async (original) => ({ + ...(await original()), + requestConfirmDialog: state.confirm, +})); +vi.mock(import("../../connection/onboarding"), async (original) => ({ + ...(await original()), + connectPairing: "connectPairing" as never, +})); +vi.mock(import("../../state/use-atom-command"), async (original) => { + const actual = await original(); + return { + ...actual, + useAtomCommand: ((command: unknown, options: never) => + command === "connectPairing" + ? state.connectPairing + : actual.useAtomCommand(command as never, options)) as never, + }; +}); + import { AppAtomRegistryProvider, appAtomRegistry } from "../../rpc/atomRegistry"; import { allReachableShellsLiveAtom } from "../../state/shell"; import { DesktopWebLinkCoordinator } from "./DesktopWebLinkCoordinator"; @@ -87,8 +115,13 @@ const pullRequestView = { }, }; +const pairingUrl = "https://vps.example.com/pair#token=ABC123"; +const pairLink = `t3code://pair?pairingUrl=${encodeURIComponent(pairingUrl)}`; + function setup() { vi.clearAllMocks(); + state.confirm.mockResolvedValue(true); + state.localEnvironmentDisabled = false; state.projects = []; appAtomRegistry.set(discoverySettled, false); } @@ -138,3 +171,62 @@ it("holds a launch PR link until project discovery settles, then opens the PR vi expect(state.navigate).toHaveBeenCalledExactlyOnceWith(pullRequestView); expect(state.openScratchProject).not.toHaveBeenCalled(); }); + +it("adds the backend from a pairing link after the user confirms its host", async () => { + setup(); + await using _coordinator = await mount(pairLink); + await vi.waitFor(() => + expect(state.connectPairing).toHaveBeenCalledExactlyOnceWith({ pairingUrl }), + ); + expect(state.confirm).toHaveBeenCalledExactlyOnceWith( + "Add the backend at https://vps.example.com?", + ); + expect(state.navigate).not.toHaveBeenCalled(); +}); + +it("does not spend the pairing token when the user declines", async () => { + setup(); + const otherPairingUrl = "https://other.example.com/pair#token=XYZ789"; + state.confirm.mockResolvedValueOnce(false); + await using _coordinator = await mount( + pairLink, + `t3code://pair?pairingUrl=${encodeURIComponent(otherPairingUrl)}`, + ); + await vi.waitFor(() => expect(state.connectPairing).toHaveBeenCalled()); + expect(state.confirm).toHaveBeenCalledTimes(2); + expect(state.connectPairing).toHaveBeenCalledExactlyOnceWith({ pairingUrl: otherPairingUrl }); +}); + +it("skips a repeat of a pairing link that already paired", async () => { + setup(); + const otherPairingUrl = "https://other.example.com/pair#token=XYZ789"; + await using _coordinator = await mount( + pairLink, + pairLink, + `t3code://pair?pairingUrl=${encodeURIComponent(otherPairingUrl)}`, + ); + await vi.waitFor(() => + expect(state.connectPairing).toHaveBeenLastCalledWith({ pairingUrl: otherPairingUrl }), + ); + expect(state.confirm).toHaveBeenCalledTimes(2); + expect(state.connectPairing.mock.calls).toEqual([ + [{ pairingUrl }], + [{ pairingUrl: otherPairingUrl }], + ]); +}); + +it("pairs with Local environment disabled, where there is no primary environment to wait for", async () => { + setup(); + state.localEnvironmentDisabled = true; + await using _coordinator = await mount(pairLink); + await vi.waitFor(() => + expect(state.connectPairing).toHaveBeenCalledExactlyOnceWith({ pairingUrl }), + ); +}); + +it("does not hold a PR link for project discovery that never settles with Local environment disabled", async () => { + setup(); + state.localEnvironmentDisabled = true; + await using _coordinator = await mount(pullRequestUrl); + await vi.waitFor(() => expect(state.openScratchProject).toHaveBeenCalledOnce()); +}); diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx index 8706f374522f0..4ae0b6cdb25b4 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx @@ -1,16 +1,23 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; +import { readPairingAppLink, resolveRemotePairingTarget } from "@t3tools/shared/remote"; import { useRouter } from "@tanstack/react-router"; import * as Effect from "effect/Effect"; import { AtomRegistry } from "effect/reactivity"; import * as Stream from "effect/Stream"; -import { useEffect, useEffectEvent, useRef } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { openFileInPreview, openUrlInPreview } from "../../browser/openFileInPreview"; import { isPreviewAvailableFor } from "../../browser/previewRuntime"; +import { requestConfirmDialog } from "../../confirmDialog"; +import { connectPairing } from "../../connection/onboarding"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { useComposerDraftStore } from "../../composerDraftStore"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; import { useScratchProject } from "../../hooks/useScratchProject"; @@ -82,19 +89,62 @@ export function DesktopWebLinkCoordinator() { reportFailure: false, refresh: true, }); + const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false }); const httpBaseUrl = useEnvironmentHttpBaseUrl(primaryEnvironment?.environmentId ?? null); const queueRef = useRef(Promise.resolve()); + const pairedUrlsRef = useRef(new Set()); const webLinks = window.desktopBridge?.webLinks; + // A sync IPC read; the setting only changes across a relaunch. + const [localEnvironmentDisabled] = useState(isLocalEnvironmentDisabled); + // With Local environment disabled there is no primary to wait for, so links run against the + // saved remote environments instead of waiting forever. const ready = webLinks !== undefined && - primaryEnvironment?.connection.phase === "connected" && - primaryEnvironment.serverConfig !== null; + (localEnvironmentDisabled || + (primaryEnvironment?.connection.phase === "connected" && + primaryEnvironment.serverConfig !== null)); const reportFailure = (url: string, description = url) => toastManager.add( stackedThreadToast({ type: "error", title: "Could not open the link", description }), ); + const reportPairingFailure = (error: unknown) => + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not add backend", + description: error instanceof Error ? error.message : "Failed to add backend.", + }), + ); + + // Any page can open a pairing link, so the user confirms the host before its one-time token is spent. + const pairFromLink = async (pairingUrl: string) => { + // A repeated click would only ask again and then fail on the spent one-time token. + if (pairedUrlsRef.current.has(pairingUrl)) return; + // The origin keeps the scheme visible, so a plain HTTP backend is not mistaken for HTTPS. + let origin: string; + try { + origin = new URL(resolveRemotePairingTarget({ pairingUrl }).httpBaseUrl).origin; + } catch (error) { + reportPairingFailure(error); + return; + } + const confirmed = (await requestConfirmDialog(`Add the backend at ${origin}?`)) ?? false; + if (!confirmed) return; + const result = await connectPairingEnvironment({ pairingUrl }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) reportPairingFailure(squashAtomCommandFailure(result)); + return; + } + pairedUrlsRef.current.add(pairingUrl); + toastManager.add({ + type: "success", + title: "Backend added", + description: "The environment is saved and will reconnect on app startup.", + }); + }; + const startThread = async (url: string): Promise => { const environmentId = scratchEnvironmentId(primaryEnvironment?.environmentId ?? null); if (environmentId === null) { @@ -114,6 +164,11 @@ export function DesktopWebLinkCoordinator() { }; const openLink = useEffectEvent(async (url: string) => { + const pairingUrl = readPairingAppLink(url); + if (pairingUrl !== null) { + await pairFromLink(pairingUrl); + return; + } const appPath = promptLinkAppPath(url); if (appPath !== null) { await router.navigate({ href: appPath }); @@ -121,6 +176,11 @@ export function DesktopWebLinkCoordinator() { } if (openChangeRequest(externalClick, url)) return; const isFile = url.startsWith("file:"); + // Only this machine's environment serves files, so fail before starting a thread. + if (isFile && httpBaseUrl === null) { + reportFailure(url); + return; + } const routeParams = router.state.matches.at(-1)?.params ?? {}; const threadRef = threadShowingBrowser( @@ -154,8 +214,11 @@ export function DesktopWebLinkCoordinator() { // A connected server publishes its config before its project snapshot, so a PR link // delivered at launch would miss its project and fall through to the browser. Hold PR // links, and only those, until discovery settles; an ordinary link never waits for it. + // Discovery never settles without the local environment, so those links do not wait. const hold = - openPullRequestsInApp && sourceControlClients.parseChangeRequestUrl(url) !== null + openPullRequestsInApp && + !localEnvironmentDisabled && + sourceControlClients.parseChangeRequestUrl(url) !== null ? projectsSettled() : Promise.resolve(); void hold.then(() => { @@ -163,6 +226,7 @@ export function DesktopWebLinkCoordinator() { }); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to desktop web link opens over the bridge and marks the renderer ready useEffect(() => { if (!ready || webLinks === undefined) return; let subscribed = true; diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.tsx index d1ff68f2aa28b..43c515810e5ef 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.tsx +++ b/apps/web/src/components/desktop/SnapShotCoordinator.tsx @@ -470,6 +470,7 @@ export function SnapShotCoordinator() { } }, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to SnapShot events from the desktop bridge and drains pending ones while enabled useEffect(() => { const bridge = getDesktopSnapShotBridge(); if (!bridge || !enabled) return; @@ -477,6 +478,7 @@ export function SnapShotCoordinator() { return bridge.onSnapShotEvent((event) => handleSnapShotEvent(bridge, event)); }, [enabled]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches window blur, focus and visibilitychange listeners to dismiss or drain SnapShot animations useEffect(() => { const dismissOnBlur = () => { pendingAnimationStartsRef.current.clear(); diff --git a/apps/web/src/components/device/DeviceDuoViewport.tsx b/apps/web/src/components/device/DeviceDuoViewport.tsx index fe3726de5dd4a..8dbac16ee7bad 100644 --- a/apps/web/src/components/device/DeviceDuoViewport.tsx +++ b/apps/web/src/components/device/DeviceDuoViewport.tsx @@ -39,6 +39,7 @@ export function DeviceDuoViewport(props: { onFrameListener, } = props; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- stores screen and model in refs, ends any active interaction, and pushes the screen into the 3D Duo viewer useEffect(() => { screenRef.current = props.screen; modelRef.current = props.model; @@ -46,6 +47,7 @@ export function DeviceDuoViewport(props: { viewerRef.current?.setScreen(props.screen); }, [props.screen, props.model]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes the hinge preview into the imperative 3D Duo viewer unless a pinch is active useEffect(() => { previewRef.current = props.hingePreview; if (!pinchRef.current?.active) viewerRef.current?.setHingePreview(props.hingePreview); diff --git a/apps/web/src/components/device/DevicePhoneViewport.tsx b/apps/web/src/components/device/DevicePhoneViewport.tsx index d524e9d9ded08..b3e038646641d 100644 --- a/apps/web/src/components/device/DevicePhoneViewport.tsx +++ b/apps/web/src/components/device/DevicePhoneViewport.tsx @@ -33,11 +33,13 @@ export function DevicePhoneViewport(props: { const accessoryRef = useRef(props.accessory); const { source, onFrameListener, client, onInputCancel, onResetReady, onUnavailable } = props; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes the fold angle into the imperative 3D phone viewer useEffect(() => { foldAngleRef.current = props.foldAngle; viewerRef.current?.setFoldAngle(props.foldAngle); }, [props.foldAngle]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes screen, profile, model and accessory changes into the imperative 3D phone viewer useEffect(() => { screenRef.current = props.screen; profileRef.current = props.profile; diff --git a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx index 40214ec0bda81..a2394854b5a8b 100644 --- a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx +++ b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx @@ -47,6 +47,7 @@ export function DiffCommentAnnotation({ const trimmedText = displayedText.trim(); const textareaRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the draft textarea on the next animation frame after mount useLayoutEffect(() => { if (kind !== "draft" || !focusOnMount) return; const frame = window.requestAnimationFrame(() => { diff --git a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx index 51716106c74be..c1af18d7a6966 100644 --- a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx +++ b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx @@ -17,6 +17,7 @@ export function DiffFileLoadingBoundary({ load, count }: { load: () => void; cou }; // A settled batch shrinks `count` without moving the boundary out of range, and no new // intersection entry arrives for that, so ask for the next batch whenever it changes. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- requests the next diff batch while the boundary is near the viewport and files remain useEffect(() => { if (isNear && count > 0) loadLatest(); }, [isNear, count, loadLatest]); diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx index 9e2ecf11b6cca..5a288c7ac0835 100644 --- a/apps/web/src/components/diffs/DiffFileTree.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -95,10 +95,12 @@ export function DiffFileTree({ areAllDirectoriesExpanded(currentModel, directoryPaths), ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes the icon set into the imperative Pierre file tree model useEffect(() => { model.setIcons(icons); }, [icons, model]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies path updates, ordering and git status to the imperative Pierre file tree model useEffect(() => { ordering.update(positions); const mounted = mountedPresentationRef.current; @@ -144,6 +146,7 @@ export function DiffFileTree({ model.setGitStatus(gitStatus); }, [directoryPaths, gitStatus, model, modelPaths, ordering, positions, presented]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands ancestors, selects and scrolls to the requested file in the Pierre tree model useEffect(() => { if (selectedPath === null) { handledRevealRef.current = null; diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index d8b2ba6fa3a5e..fb876688dad79 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -47,7 +47,7 @@ import VirtualTablePreview from "./VirtualTablePreview"; import { OfficeFilePreview } from "./OfficeFilePreview"; import { MermaidFilePreview } from "./MermaidFilePreview"; import JSZip from "jszip"; -import { boundedZip } from "./boundedZip"; +import { boundedZip } from "@t3tools/client-runtime/zip/bounded-zip"; import BinaryViewer from "./BinaryViewer"; import { decodeImagePreview } from "./decodeImagePreview"; import type { BinaryWorkerResult } from "./binaryPreviewProtocol"; diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 5ab933a565c19..e57289bd60144 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -139,6 +139,7 @@ export function AttachmentFilePreview(props: { const [contentError, setContentError] = useState(null); const copyContents = useRef<(() => Promise) | null>(null); const authorizedAt = useRef(0); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates an object URL for the local file and revokes it on cleanup useEffect(() => { if (!props.file) return; const url = URL.createObjectURL(props.file); @@ -146,6 +147,7 @@ export function AttachmentFilePreview(props: { setLocalUrl(url); return () => URL.revokeObjectURL(url); }, [props.file]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- authorizes a remote attachment URL from the server and ignores the result after cleanup useEffect(() => { if (props.file) return; let cancelled = false; @@ -175,6 +177,7 @@ export function AttachmentFilePreview(props: { (props.sizeBytes === 0 && (!resource || resource._tag === "attachment"))); const needsText = kind === "text" || kind === "markdown" || kind === "mermaid" || (kind === "html" && !rendered); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fetches the file's text from its URL or Blob stream and aborts the request on cleanup useEffect(() => { if (!needsText || streamsText || !url) return; const controller = new AbortController(); diff --git a/apps/web/src/components/files/AudioPreview.tsx b/apps/web/src/components/files/AudioPreview.tsx index 735a56e524e02..0a8f4bcfe16c9 100644 --- a/apps/web/src/components/files/AudioPreview.tsx +++ b/apps/web/src/components/files/AudioPreview.tsx @@ -111,7 +111,7 @@ function AudioChip(props: AudioPreviewProps) { 0 ? Math.min(100, (elapsed / duration) * 100) : 0}%` }} /> { @@ -154,7 +154,7 @@ function AudioChip(props: AudioPreviewProps) { middleTruncateAttachmentName(props.name) )} - + {props.failed ? "Unavailable" : loading diff --git a/apps/web/src/components/files/BinaryViewer.tsx b/apps/web/src/components/files/BinaryViewer.tsx index 74a26455a3bf9..e87842efbed9f 100644 --- a/apps/web/src/components/files/BinaryViewer.tsx +++ b/apps/web/src/components/files/BinaryViewer.tsx @@ -46,7 +46,7 @@ function ArchiveFile(props: { readonly path: string; readonly bytes: Uint8Array< return null; } })(); - // oxlint-disable-next-line t3code/react-effect-budget -- Create an object URL for the extracted image and revoke it when the entry changes or unmounts. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Create an object URL for the extracted image and revoke it when the entry changes or unmounts. useEffect(() => { if (kind !== "image") return; const extension = props.path.slice(props.path.lastIndexOf(".")); @@ -108,7 +108,7 @@ export default function BinaryViewer(props: { }, 15_000); workerRef.current?.postMessage(request); }; - // oxlint-disable-next-line t3code/react-effect-budget -- Own the binary fetch, archive or database worker, message subscription, and request timeout for this file. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the binary fetch, archive or database worker, message subscription, and request timeout for this file. useEffect(() => { const controller = new AbortController(); const worker = new Worker(new URL("./binaryPreview.worker.ts", import.meta.url), { diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index 757d37a783f01..e8d3139a8ae70 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -156,6 +156,7 @@ export function HtmlRenderDocument(props: { const postTheme = () => { frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- posts the current theme to the sandboxed iframe's window useEffect(postTheme, [theme]); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const environmentKeybindings = useAtomValue( @@ -190,6 +191,7 @@ export function HtmlRenderDocument(props: { "*", ); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- posts allowed and blocked shortcuts to the sandboxed iframe's window useEffect(postShortcuts, [blocked, shortcuts]); // Pages can forge key/code pairs; reject pairs matching other bindings and require named keys to match the // shortcut. @@ -230,6 +232,7 @@ export function HtmlRenderDocument(props: { void openLink(url, { event: modifiers }).catch((cause: unknown) => console.error(cause)); }); const { onFullscreenChange } = props; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports fullscreen to the parent while the image viewer is open, and clears it on unmount useEffect(() => { if (expandedImage === null) return; onFullscreenChange?.(true); @@ -266,6 +269,7 @@ export function HtmlRenderDocument(props: { } }); // Attach at commit because a fast page may post its initial height before passive effects run. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches a window message listener for the iframe at commit, before its first height post useLayoutEffect(() => { const frame = frameRef.current; if (frame === null) return; diff --git a/apps/web/src/components/files/DocumentPages.tsx b/apps/web/src/components/files/DocumentPages.tsx index 02715be64fb58..b582ae67141b9 100644 --- a/apps/web/src/components/files/DocumentPages.tsx +++ b/apps/web/src/components/files/DocumentPages.tsx @@ -78,7 +78,7 @@ export default function DocumentPages(props: { }) { const { theme, resolvedTheme, themeHalves } = useTheme(); const [colors, setColors] = useState(documentColors); - // oxlint-disable-next-line t3code/react-effect-budget -- Read the host DOM's applied theme tokens so the sandboxed document receives the current palette. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Read the host DOM's applied theme tokens so the sandboxed document receives the current palette. useEffect(() => { // The iframe cannot inherit the host palette across its sandbox boundary. // oxlint-disable-next-line react/set-state-in-effect -- Read tokens after useTheme applies the selected palette. @@ -88,7 +88,7 @@ export default function DocumentPages(props: { const [zoom, setZoom] = useState(1); const [outputHeight, setOutputHeight] = useState(0); const stopObserving = useRef<(() => void) | null>(null); - // oxlint-disable-next-line t3code/react-effect-budget -- Disconnect the iframe body's resize observer when the document unmounts. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Disconnect the iframe body's resize observer when the document unmounts. useEffect(() => () => stopObserving.current?.(), []); const html = sanitizeDocumentHtml(props.html); const srcDoc = `${html}`; diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index faed4a113b8d5..7a6fa3a9405f2 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -294,6 +294,7 @@ export default function FileBrowserPanel({ onSearchChange: (value) => setQuery(value ?? ""), unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- makes the Pierre tree model re-render row decorations when loading directories change useEffect(() => { if (loadingDirectoriesRef.current === loadingDirectories) return; loadingDirectoriesRef.current = loadingDirectories; @@ -319,6 +320,7 @@ export default function FileBrowserPanel({ search.close(); }; const expandedPathsRef = useRef(new Set()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the Pierre tree model to load directories as they are expanded useEffect(() => { const currentPaths = new Set(directoryPaths); for (const path of expandedPathsRef.current) { @@ -342,6 +344,7 @@ export default function FileBrowserPanel({ loadExpanded(); return model.subscribe(loadExpanded); }, [directoryPaths, load, model]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes ignored-file status into the imperative Pierre tree model useEffect(() => { model.setGitStatus( entries @@ -352,6 +355,7 @@ export default function FileBrowserPanel({ })), ); }, [entries, model]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the selected file's ancestor directories and aborts on cleanup useEffect(() => { if (!selectedPath) return; const controller = new AbortController(); @@ -382,6 +386,7 @@ export default function FileBrowserPanel({ resourceKey: `files:${environmentId}:${cwd}`, }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies tree path changes to the imperative Pierre tree model as a reset or batch update useEffect(() => { if (!ready) return; if (previousTreePathsRef.current === treePaths) return; @@ -396,11 +401,13 @@ export default function FileBrowserPanel({ if (updates.length > 0) model.batch(updates); }, [ready, entryKinds, model, treePaths]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands every directory in the Pierre tree model while expand-all is on and no search is active useEffect(() => { if (expandAll && !query.trim()) setAllDirectoriesExpanded(model, expandableDirectoryPaths, true); }, [expandableDirectoryPaths, expandAll, model, query]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals an externally selected path in the Pierre tree model by expanding, selecting and scrolling useEffect(() => { if (!selectedPath) { handledRevealRef.current = null; diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index b8b5376c01768..56854b3ec6f72 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -620,6 +620,7 @@ function useEditableAfterHighlight(file: FileContents) { const needsHighlight = needsWorkerHighlight(workerPool, file); const ready = !needsHighlight || highlightedFile === file; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- primes the worker pool's highlight cache for this file and falls back to plain text if it fails useEffect(() => { if (ready || workerPool === undefined) return; workerPool.primeFileHighlightCache(file).catch(() => setHighlightedFile(file)); @@ -812,6 +813,7 @@ function EditableFileSurface({ const hasOpenCommentForm = lineAnnotations.some((annotation) => annotation.metadata.entries.some((entry) => entry.kind === "draft"), ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- installs selection dismissal listeners on the editor surface DOM node useEffect(() => { const root = surfaceRef.current; if (!root) return; @@ -1163,6 +1165,7 @@ export default function FilePreviewPanel({ : NO_FILE_REVISION_ATOM, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the breadcrumb DOM so the deepest and current crumbs are in view after commit useEffect(() => { const crumbs = breadcrumbRef.current?.querySelectorAll("[data-file-crumb]"); // Show the deepest crumb when it fits, but never at the cost of the current one. diff --git a/apps/web/src/components/files/FontViewer.tsx b/apps/web/src/components/files/FontViewer.tsx index c88f510924493..fccb5a332ef85 100644 --- a/apps/web/src/components/files/FontViewer.tsx +++ b/apps/web/src/components/files/FontViewer.tsx @@ -42,7 +42,7 @@ export default function FontViewer(props: { readonly src: string; readonly name: const { resolvedTheme } = useTheme(); const [data, setData] = useState(null); const [error, setError] = useState(null); - // oxlint-disable-next-line t3code/react-effect-budget -- Load the font from its URL and abort the request and timeout when the source changes or unmounts. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Load the font from its URL and abort the request and timeout when the source changes or unmounts. useEffect(() => { const controller = new AbortController(); const timeout = setTimeout(() => { diff --git a/apps/web/src/components/files/InlineAudioPreview.tsx b/apps/web/src/components/files/InlineAudioPreview.tsx index 0db2f835379ff..6cc0e18f047df 100644 --- a/apps/web/src/components/files/InlineAudioPreview.tsx +++ b/apps/web/src/components/files/InlineAudioPreview.tsx @@ -23,6 +23,7 @@ export function InlineAudioPreview(props: { const src = visible ? (asset._tag === "Success" ? asset.url : props.src) : undefined; const failed = asset._tag === "Failure" || (src !== undefined && failedSrc === src); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- IntersectionObserver defers loading the audio until the element scrolls into view useEffect(() => { const element = container.current; if (!element) return; diff --git a/apps/web/src/components/files/LargeTextViewer.tsx b/apps/web/src/components/files/LargeTextViewer.tsx index ae645f03417c7..a12842f7047c9 100644 --- a/apps/web/src/components/files/LargeTextViewer.tsx +++ b/apps/web/src/components/files/LargeTextViewer.tsx @@ -47,7 +47,7 @@ export default function LargeTextViewer(props: { } | null>(null); const pending = useRef(new Set()); const wordWrap = useClientSettings((settings) => settings.wordWrap); - // oxlint-disable-next-line t3code/react-effect-budget -- Own the prefix fetch and indexing worker, cancelling requests and pending copies when the source changes or unmounts. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the prefix fetch and indexing worker, cancelling requests and pending copies when the source changes or unmounts. useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- A replacement source must discard the previous worker's error and pages. setError(null); diff --git a/apps/web/src/components/files/NotebookPreview.tsx b/apps/web/src/components/files/NotebookPreview.tsx index c32b9f84cd23d..d5d56bb0d6d61 100644 --- a/apps/web/src/components/files/NotebookPreview.tsx +++ b/apps/web/src/components/files/NotebookPreview.tsx @@ -57,7 +57,7 @@ export default function NotebookPreview(props: { (count, cell) => count + cell.outputs.filter((output) => output.kind === "html").length, 0, ); - // oxlint-disable-next-line t3code/react-effect-budget -- Observe output visibility and measure departing outputs to bound iframe lifetimes while preserving scroll height. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Observe output visibility and measure departing outputs to bound iframe lifetimes while preserving scroll height. useEffect(() => { const root = scroll.current; if (!root || !outputCount) return; diff --git a/apps/web/src/components/files/OfficeViewer.tsx b/apps/web/src/components/files/OfficeViewer.tsx index dcec162674546..1af5f122a83af 100644 --- a/apps/web/src/components/files/OfficeViewer.tsx +++ b/apps/web/src/components/files/OfficeViewer.tsx @@ -85,7 +85,7 @@ function ExtendedOfficeViewer(props: { const [page, setPage] = useState(0); const workerRef = useRef(null); const timerRef = useRef | null>(null); - // oxlint-disable-next-line t3code/react-effect-budget -- Own the Office fetch, preparation worker, and timeout for this source and release them on cleanup. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the Office fetch, preparation worker, and timeout for this source and release them on cleanup. useEffect(() => { const controller = new AbortController(); if ((props.sizeBytes ?? 0) > OFFICE_PREVIEW_MAX_BYTES) { diff --git a/apps/web/src/components/files/OpenXmlOfficeViewer.tsx b/apps/web/src/components/files/OpenXmlOfficeViewer.tsx index d17d1393807af..8802e9fd2913e 100644 --- a/apps/web/src/components/files/OpenXmlOfficeViewer.tsx +++ b/apps/web/src/components/files/OpenXmlOfficeViewer.tsx @@ -45,7 +45,7 @@ export default function OfficeViewer(props: { const fail = useStableCallback((cause: unknown) => setError(cause instanceof Error ? cause.message : "Could not render this Office document."), ); - // oxlint-disable-next-line t3code/react-effect-budget -- Own the Open XML fetch, preparation worker, and timeout for this source and release them on cleanup. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the Open XML fetch, preparation worker, and timeout for this source and release them on cleanup. useEffect(() => { if ((props.sizeBytes ?? 0) > OFFICE_PREVIEW_MAX_BYTES) { // Metadata bounds are checked before fetching or starting an engine. diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index 4d86b66efb731..542f4031d48fd 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -42,7 +42,7 @@ function PdfPage(props: { const canvas = useRef(null); const text = useRef(null); const { document: pdf, index, width, onSize, onError } = props; - // oxlint-disable-next-line t3code/react-effect-budget -- Render the PDF page canvas and text layer, cancelling tasks and releasing pdf.js page resources on cleanup. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Render the PDF page canvas and text layer, cancelling tasks and releasing pdf.js page resources on cleanup. useEffect(() => { let cancelled = false; let paint: ReturnType>["render"]> | undefined; @@ -91,7 +91,7 @@ function PdfPage(props: { void Promise.allSettled([paint?.promise]).then(() => loadedPage?.cleanup()); }; }, [pdf, index, width, onSize, onError]); - // oxlint-disable-next-line t3code/react-effect-budget -- Highlight pdf.js-owned text nodes and observe asynchronous text layer mutations for the current search query. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Highlight pdf.js-owned text nodes and observe asynchronous text layer mutations for the current search query. useEffect(() => { const node = text.current; if (!node) return; @@ -133,7 +133,7 @@ export default function PdfViewer(props: { src: string; title: string }) { const scroll = useRef(null); const find = useRef(null); const zoomAnchor = useRef<{ page: number; offset: number; width: number } | null>(null); - // oxlint-disable-next-line t3code/react-effect-budget -- Own the PDF fetch, load timeout, and pdf.js document task across source changes, retries, and unmounts. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the PDF fetch, load timeout, and pdf.js document task across source changes, retries, and unmounts. useEffect(() => { const controller = new AbortController(); let disposed = false; @@ -173,7 +173,7 @@ export default function PdfViewer(props: { src: string; title: string }) { void task?.destroy(); }; }, [props.src, revision]); - // oxlint-disable-next-line t3code/react-effect-budget -- Measure the PDF viewport before paint and observe resizes for fit-width sizing and visible-page selection. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Measure the PDF viewport before paint and observe resizes for fit-width sizing and visible-page selection. useLayoutEffect(() => { const node = scroll.current; if (!node) return; @@ -206,7 +206,7 @@ export default function PdfViewer(props: { src: string; title: string }) { }; setZoom(value); }; - // oxlint-disable-next-line t3code/react-effect-budget -- Restore the DOM scroll anchor after the new zoom width has committed to page layout. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restore the DOM scroll anchor after the new zoom width has committed to page layout. useLayoutEffect(() => { const anchor = zoomAnchor.current; zoomAnchor.current = null; @@ -215,11 +215,11 @@ export default function PdfViewer(props: { src: string; title: string }) { top: offsets[anchor.page]! - 16 + (anchor.offset * width) / anchor.width, }); }); - // oxlint-disable-next-line t3code/react-effect-budget -- Scroll to the first asynchronous search result after its measured page offsets have committed. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Scroll to the first asynchronous search result after its measured page offsets have committed. useLayoutEffect(() => { if (matches.length) jumpToPage(matches[0]!); }, [matches]); - // oxlint-disable-next-line t3code/react-effect-budget -- Debounce pdf.js text extraction and cancel obsolete searches when the query or document changes. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Debounce pdf.js text extraction and cancel obsolete searches when the query or document changes. useEffect(() => { if (!pdf || !query.trim()) { // oxlint-disable-next-line react/set-state-in-effect -- A blank query cancels the previous search. diff --git a/apps/web/src/components/files/PreviewImageContent.tsx b/apps/web/src/components/files/PreviewImageContent.tsx index ab673d92cb8da..9f57b464ccdbb 100644 --- a/apps/web/src/components/files/PreviewImageContent.tsx +++ b/apps/web/src/components/files/PreviewImageContent.tsx @@ -17,7 +17,7 @@ export default function PreviewImageContent(props: { const [viewport, setViewport] = useState({ width: 0, height: 0 }); const [zoom, setZoom] = useState(null); const ref = useRef(null); - // oxlint-disable-next-line t3code/react-effect-budget -- Measure the image viewport before paint and observe resizes to keep fit-to-view scaling accurate. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Measure the image viewport before paint and observe resizes to keep fit-to-view scaling accurate. useLayoutEffect(() => { const node = ref.current; if (!node) return; diff --git a/apps/web/src/components/files/VirtualTablePreview.tsx b/apps/web/src/components/files/VirtualTablePreview.tsx index f5ffd3793cf15..5154153807df1 100644 --- a/apps/web/src/components/files/VirtualTablePreview.tsx +++ b/apps/web/src/components/files/VirtualTablePreview.tsx @@ -23,7 +23,7 @@ export default function VirtualTablePreview(props: { const widthRef = useRef(0); const measureRef = useRef(null); const [measured, setMeasured] = useState(null); - // oxlint-disable-next-line t3code/react-effect-budget -- Measure the viewport before paint and observe container resizes to invalidate column measurements. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Measure the viewport before paint and observe container resizes to invalidate column measurements. useLayoutEffect(() => { const node = ref.current; if (!node) return; @@ -39,7 +39,7 @@ export default function VirtualTablePreview(props: { update(); return observeResize(node, update); }, []); - // oxlint-disable-next-line t3code/react-effect-budget -- Read the browser's laid-out table column widths before displaying the virtual table. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Read the browser's laid-out table column widths before displaying the virtual table. useLayoutEffect(() => { if (!width || !props.header || !measureRef.current || measured) return; const widths = [...measureRef.current.querySelectorAll("thead th")].map( @@ -184,7 +184,7 @@ function RowPage( const rows = props.getRows(props.page); // A page stays mounted while the worker fills it, preserving the scroll position. const { loadPage, page } = props; - // oxlint-disable-next-line t3code/react-effect-budget -- Request missing rows from the indexing worker while preserving the mounted page's scroll position. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Request this page's rows from the indexing worker when they are missing or incomplete. useEffect(() => { const expected = Math.min(PREVIEW_PAGE_ROWS, props.count - page * PREVIEW_PAGE_ROWS); if (!rows || rows.length < expected) loadPage?.(page); @@ -274,7 +274,7 @@ function ScaledRows(props: Omit[0], "page">) { // The final mounted rows keep their measured height and scroll natively. const nativeTail = first - headerRows === maxFirst; const scrollModel = useRef({ scale, maxFirst, heights: [] as number[] }); - // oxlint-disable-next-line t3code/react-effect-budget -- Measure native tail rows and preserve the DOM scroll position when the virtual track scale changes. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Measure native tail rows and preserve the DOM scroll position when the virtual track scale changes. useLayoutEffect(() => { const previous = scrollModel.current; const heights = nativeTail @@ -304,7 +304,7 @@ function ScaledRows(props: Omit[0], "page">) { node.scrollTop = Math.min(maxFirst * 29, logicalTop) * scale + nativeOffset; setTop(node.scrollTop); }); - // oxlint-disable-next-line t3code/react-effect-budget -- Scroll the DOM viewport to a requested line after its virtual track has committed. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Scroll the DOM viewport to a requested line after its virtual track has committed. useLayoutEffect(() => { const node = ref.current; if (props.revealLine == null) { @@ -335,7 +335,7 @@ function ScaledRows(props: Omit[0], "page">) { pendingEnd.current = false; node.scrollTop += delta * (nativeTail ? 1 : (scale * 29) / rowHeight()); }); - // oxlint-disable-next-line t3code/react-effect-budget -- Install a non-passive native wheel listener so virtual scrolling can prevent the browser's default scroll. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Install a non-passive native wheel listener so virtual scrolling can prevent the browser's default scroll. useEffect(() => { const node = ref.current; if (!node) return; @@ -347,7 +347,7 @@ function ScaledRows(props: Omit[0], "page">) { }, []); const page = Math.floor(first / PREVIEW_PAGE_ROWS); const visibleEnd = (first % PREVIEW_PAGE_ROWS) + Math.ceil(position.height / 29) + 1; - // oxlint-disable-next-line t3code/react-effect-budget -- Measure mounted row offsets and observe content resizes to position the virtual rows against the scroll track. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Measure mounted row offsets and observe content resizes to position the virtual rows against the scroll track. useLayoutEffect(() => { if (atStart) return; const node = contentRef.current; @@ -366,7 +366,7 @@ function ScaledRows(props: Omit[0], "page">) { update(); return observeResize(node, update); }, [first, atStart]); - // oxlint-disable-next-line t3code/react-effect-budget -- Correct the DOM scroll position once a requested worker row has loaded and its actual bounds are measurable. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Correct the DOM scroll position once a requested worker row has loaded and its actual bounds are measurable. useLayoutEffect(() => { const pending = pendingReveal.current; const node = ref.current; @@ -406,7 +406,7 @@ function ScaledRows(props: Omit[0], "page">) { node.scrollTop += delta * (nativeTail ? 1 : (scale * 29) / Math.max(29, bounds.height)); setTop(node.scrollTop); }); - // oxlint-disable-next-line t3code/react-effect-budget -- Complete End-key scrolling after the final worker row has loaded and its DOM layout has settled. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Complete End-key scrolling after the final worker row has loaded and its DOM layout has settled. useLayoutEffect(() => { const node = ref.current; const content = contentRef.current; diff --git a/apps/web/src/components/files/binaryPreview.worker.ts b/apps/web/src/components/files/binaryPreview.worker.ts index 1b170d3cb524b..c0f3c2a17410f 100644 --- a/apps/web/src/components/files/binaryPreview.worker.ts +++ b/apps/web/src/components/files/binaryPreview.worker.ts @@ -1,6 +1,6 @@ import type { Database } from "sql.js"; -import { boundedZip } from "./boundedZip"; +import { boundedZip } from "@t3tools/client-runtime/zip/bounded-zip"; import { ARCHIVE_ENTRY_MAX_BYTES, ARCHIVE_MAX_ENTRIES, diff --git a/apps/web/src/components/files/binaryPreviewProtocol.ts b/apps/web/src/components/files/binaryPreviewProtocol.ts index 137c021ae965b..ce94ce4ea0975 100644 --- a/apps/web/src/components/files/binaryPreviewProtocol.ts +++ b/apps/web/src/components/files/binaryPreviewProtocol.ts @@ -1,7 +1,11 @@ -export const BINARY_PREVIEW_MAX_BYTES = 50 * 1024 * 1024; -export const ARCHIVE_ENTRY_MAX_BYTES = 10 * 1024 * 1024; -export const ARCHIVE_MAX_ENTRIES = 2048; -export const ARCHIVE_MAX_RATIO = 200; +import { ARCHIVE_MAX_BYTES } from "@t3tools/client-runtime/zip/bounded-zip"; + +export { + ARCHIVE_ENTRY_MAX_BYTES, + ARCHIVE_MAX_ENTRIES, + ARCHIVE_MAX_RATIO, +} from "@t3tools/client-runtime/zip/bounded-zip"; +export const BINARY_PREVIEW_MAX_BYTES = ARCHIVE_MAX_BYTES; export const SQLITE_PAGE_SIZE = 50; export interface ArchiveEntry { diff --git a/apps/web/src/components/files/fileSaveSessions.ts b/apps/web/src/components/files/fileSaveSessions.ts index 8c007347a7348..69550e3ba78df 100644 --- a/apps/web/src/components/files/fileSaveSessions.ts +++ b/apps/web/src/components/files/fileSaveSessions.ts @@ -166,7 +166,7 @@ export function useFileSaveSession( { environmentId, cwd, relativePath }: FileSaveTarget, onPendingChange: FilePendingListener, ): void { - // oxlint-disable-next-line t3code/react-effect-budget -- the lease is a mount-scoped external resource; React runs every unmount cleanup before mount setups, so a surface switch flushes before the next surface recovers. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the lease is a mount-scoped external resource; React runs every unmount cleanup before mount setups, so a surface switch flushes before the next surface recovers. useEffect( () => fileSaveSessions.acquire({ environmentId, cwd, relativePath }, onPendingChange), [environmentId, cwd, relativePath, onPendingChange], diff --git a/apps/web/src/components/files/office/OfficePages.tsx b/apps/web/src/components/files/office/OfficePages.tsx index d7b4b78c345e6..973b8a1fedaae 100644 --- a/apps/web/src/components/files/office/OfficePages.tsx +++ b/apps/web/src/components/files/office/OfficePages.tsx @@ -36,7 +36,7 @@ export function OfficePages(props: { const [width, setWidth] = useState(0); const [settledWidth, setSettledWidth] = useState(0); const [mounted, setMounted] = useState(6); - // oxlint-disable-next-line t3code/react-effect-budget -- Observe the scroll container width and debounce raster resizing, releasing the observer and timer on unmount. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Observe the scroll container width and debounce raster resizing, releasing the observer and timer on unmount. useEffect(() => { const root = scroller.current; if (!root) return; @@ -53,7 +53,7 @@ export function OfficePages(props: { unobserve(); }; }, []); - // oxlint-disable-next-line t3code/react-effect-budget -- Yield between page mounting batches with a timer so large documents do not block the browser. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Yield between page mounting batches with a timer so large documents do not block the browser. useEffect(() => { if (mounted >= props.pages.length) return; const timer = setTimeout(() => setMounted((n) => Math.min(props.pages.length, n + 50)), 0); @@ -146,7 +146,7 @@ function OfficePage(props: { const canvas = useRef(null); const [near, setNear] = useState(false); const [ratio, setRatio] = useState(window.devicePixelRatio || 1); - // oxlint-disable-next-line t3code/react-effect-budget -- Observe the page's viewport proximity to limit painting and text layers to nearby pages. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Observe the page's viewport proximity to limit painting and text layers to nearby pages. useEffect(() => { if (!row.current) return; const observer = new IntersectionObserver( @@ -156,14 +156,14 @@ function OfficePage(props: { observer.observe(row.current); return () => observer.disconnect(); }, [root]); - // oxlint-disable-next-line t3code/react-effect-budget -- Subscribe to display density changes so canvas rasters match the current screen. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Subscribe to display density changes so canvas rasters match the current screen. useEffect(() => { const query = matchMedia(`(resolution: ${ratio}dppx)`); const change = () => setRatio(window.devicePixelRatio || 1); query.addEventListener("change", change); return () => query.removeEventListener("change", change); }, [ratio]); - // oxlint-disable-next-line t3code/react-effect-budget -- Paint the visible page canvas at the current raster scale and cancel obsolete paint requests. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Paint the visible page canvas at the current raster scale and cancel obsolete paint requests. useEffect(() => { const target = canvas.current; if (!target || !page || !near || ratio <= 0 || rasterScale <= 0) return; @@ -173,7 +173,7 @@ function OfficePage(props: { }); return () => controller.abort(); }, [near, index, page, paint, rasterScale, onError, ratio]); - // oxlint-disable-next-line t3code/react-effect-budget -- Release the canvas backing store when its page moves away from the viewport. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Release the canvas backing store when its page moves away from the viewport. useEffect(() => { const target = canvas.current; if (!near && target) { diff --git a/apps/web/src/components/files/office/Presentation.tsx b/apps/web/src/components/files/office/Presentation.tsx index b3a456c646477..891e40f91ad56 100644 --- a/apps/web/src/components/files/office/Presentation.tsx +++ b/apps/web/src/components/files/office/Presentation.tsx @@ -26,7 +26,7 @@ export default function Presentation(props: { const [page, setPage] = useState(0); const [failed, setFailed] = useState>({}); const scroll = useRef<((index: number, y?: number) => void) | null>(null); - // oxlint-disable-next-line t3code/react-effect-budget -- Open and paint slides through a worker whose requests and lifetime belong to this presentation. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Open and paint slides through a worker whose requests and lifetime belong to this presentation. useEffect(() => { const controller = new AbortController(); const worker = new Worker(new URL("./presentation.worker.ts", import.meta.url), { diff --git a/apps/web/src/components/files/office/Spreadsheet.tsx b/apps/web/src/components/files/office/Spreadsheet.tsx index e9789d415d4a3..6faa42d924c08 100644 --- a/apps/web/src/components/files/office/Spreadsheet.tsx +++ b/apps/web/src/components/files/office/Spreadsheet.tsx @@ -49,7 +49,7 @@ export default function Spreadsheet(props: { const [opened, setOpened] = useState(); const [error, setError] = useState(""); const picking = useRef(null); - // oxlint-disable-next-line t3code/react-effect-budget -- Open the workbook worker and cancel workbook and sheet requests when the source changes or unmounts. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Open the workbook worker and cancel workbook and sheet requests when the source changes or unmounts. useEffect(() => { const controller = new AbortController(); const worker = new Worker(new URL("./spreadsheet.worker.ts", import.meta.url), { @@ -163,11 +163,11 @@ function SpreadsheetGrid(props: { const [reader, setReader] = useState<{ row: number; col: number; text: string }[]>([]); const [active, setActive] = useState({ row: 0, col: 0 }); const navigate = useRef(onNavigate); - // oxlint-disable-next-line t3code/react-effect-budget -- Keep the installed native pointer listener calling the latest navigation handler without restarting the canvas engine. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Keep the installed native pointer listener calling the latest navigation handler without restarting the canvas engine. useEffect(() => { navigate.current = onNavigate; }, [onNavigate]); - // oxlint-disable-next-line t3code/react-effect-budget -- Own canvas painting, worker requests, resize observation, and native scroll, keyboard, and pointer listeners for this sheet. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own canvas painting, worker requests, resize observation, and native scroll, keyboard, and pointer listeners for this sheet. useEffect(() => { const root = scroller.current; const target = canvas.current; diff --git a/apps/web/src/components/files/office/WordDocument.tsx b/apps/web/src/components/files/office/WordDocument.tsx index 44d8b6f58c53a..39ec9e982d215 100644 --- a/apps/web/src/components/files/office/WordDocument.tsx +++ b/apps/web/src/components/files/office/WordDocument.tsx @@ -28,7 +28,7 @@ export default function WordDocument(props: { const { onError, bytes, onDetails } = props; const [opened, setOpened] = useState(); const scroll = useRef<((index: number, y?: number) => void) | null>(null); - // oxlint-disable-next-line t3code/react-effect-budget -- Own the document worker and cancel its layout and painting requests when the document changes or unmounts. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the document worker and cancel its layout and painting requests when the document changes or unmounts. useEffect(() => { const controller = new AbortController(); const worker = new Worker(new URL("./document.worker.ts", import.meta.url), { type: "module" }); @@ -121,7 +121,7 @@ function WordTextLayer(props: { }) { const { engine, index, page, follow } = props; const [spans, setSpans] = useState([]); - // oxlint-disable-next-line t3code/react-effect-budget -- Request selectable text from the document worker and cancel the request when its page leaves the viewport. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Request selectable text from the document worker and cancel the request when its page leaves the viewport. useEffect(() => { if (!page) return; const controller = new AbortController(); diff --git a/apps/web/src/components/files/officeArchive.ts b/apps/web/src/components/files/officeArchive.ts index fe098f6979d76..1d95266213243 100644 --- a/apps/web/src/components/files/officeArchive.ts +++ b/apps/web/src/components/files/officeArchive.ts @@ -1,5 +1,5 @@ import JSZip from "jszip"; -import { checkZipDirectory } from "./zipDirectory"; +import { checkZipDirectory } from "@t3tools/client-runtime/zip/directory"; import { SaxesParser } from "saxes"; import { externalUrl } from "./office/links"; diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts index 30327e6f71fcb..d9517aad79191 100644 --- a/apps/web/src/components/files/useDirectoryEntries.ts +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -94,6 +94,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { return startRequest(directoryPath, refresh); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts the root directory read on mount and marks the hook inactive so late reads are ignored useEffect(() => { active.current = true; if (!requests.current.has("") && !loaded.current.has("")) void startRequest("", false); diff --git a/apps/web/src/components/guest/GuestHtmlRender.tsx b/apps/web/src/components/guest/GuestHtmlRender.tsx index 75b7445ac6e8b..4654d947b82b4 100644 --- a/apps/web/src/components/guest/GuestHtmlRender.tsx +++ b/apps/web/src/components/guest/GuestHtmlRender.tsx @@ -38,6 +38,7 @@ export function GuestHtmlRender({ () => `${GUEST_RENDER_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&item=${encodeURIComponent(itemId)}${htmlRenderThemeFragment(theme)}`, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the frame box and follows its width with a ResizeObserver before paint useLayoutEffect(() => { const box = boxRef.current; if (!box) return; @@ -46,9 +47,11 @@ export function GuestHtmlRender({ if (entry) setWidth(entry.contentRect.width); }); }, []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- posts theme changes to the iframe's window useEffect(() => { frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); }, [theme]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window message listener for the iframe's height reports and link requests useEffect(() => { const onMessage = (event: MessageEvent) => { const frame = frameRef.current; diff --git a/apps/web/src/components/guest/GuestPage.tsx b/apps/web/src/components/guest/GuestPage.tsx index e860920692ff0..44820b0b1d2e8 100644 --- a/apps/web/src/components/guest/GuestPage.tsx +++ b/apps/web/src/components/guest/GuestPage.tsx @@ -175,6 +175,7 @@ export function GuestPage() { // A browser that already joined this share goes straight back in, even from the spent link. // Sessions are per share, so one for another link never stands in for this one. const [access, setAccess] = useState(shareId === null ? "none" : "checking"); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fetches whether this browser already has a guest session and ignores the answer after cleanup useEffect(() => { if (shareId === null) return; let live = true; @@ -195,6 +196,7 @@ export function GuestPage() { }, [credential, shareId]); // Pasting another guest link into this tab changes only the hash, which does not reload the // page; reload so the new invite is read instead of the one already captured. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hashchange listener reloads the page when another guest link is pasted into this tab useEffect(() => { const onHashChange = () => { if (window.location.hash !== "") window.location.reload(); @@ -233,15 +235,18 @@ function GuestSession({ shareId }: { readonly shareId: string }) { ...initialGuestStreamState, awaitingRowIds: readAwaitingRowIds(), })); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- persists the awaiting row ids to sessionStorage so a reload keeps them useEffect(() => writeAwaitingRowIds(stream.awaitingRowIds), [stream.awaitingRowIds]); // EventSource closes for good only on a non-200 response, which the guest routes send for a // missing, invalid, or ended session. Network drops reconnect on their own. const [rejected, setRejected] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- sets document.title to the shared thread's title useEffect(() => { if (stream.view) document.title = stream.view.title; }, [stream.view]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the guest EventSource stream and closes it on cleanup useEffect(() => { const source = new EventSource( `${GUEST_ROUTE_PREFIX}/stream?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}`, @@ -419,6 +424,7 @@ function GuestThread({ const previousUnplacedKey = useRef(unplacedKey); const following = useRef(follow.timelineLiveFollowEnabled); const scrollToEnd = useStableCallback(follow.scrollToEnd); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the timeline to the end after commit when new unplaced messages appear useEffect(() => { following.current = follow.timelineLiveFollowEnabled; const previous = new Set(previousUnplacedKey.current.split(",")); @@ -427,6 +433,7 @@ function GuestThread({ }, [follow.timelineLiveFollowEnabled, unplacedKey, scrollToEnd, following, previousUnplacedKey]); const footerRef = useRef(null); const hasFooter = unplaced.length > 0; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- ResizeObserver on the footer keeps it scrolled into view while following useEffect(() => { const footer = footerRef.current; if (!hasFooter || footer === null) return; diff --git a/apps/web/src/components/media/AttachmentImage.tsx b/apps/web/src/components/media/AttachmentImage.tsx index ea1a7c19057d7..ce39f5d7ac5c4 100644 --- a/apps/web/src/components/media/AttachmentImage.tsx +++ b/apps/web/src/components/media/AttachmentImage.tsx @@ -48,7 +48,7 @@ function DecodedImage({ name, mimeType, src, onError, ...props }: AttachmentImag }); }; - // oxlint-disable-next-line t3code/react-effect-budget -- the async decode and its blob URL live as long as this component + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the async decode and its blob URL live as long as this component useEffect( () => () => { const attempt = decodeRef.current; diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index da707b549acee..8aa16fe030cf8 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -140,6 +140,7 @@ export function FirstRunGate({ if (transitioned !== gateState) setGateState(transitioned); } + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- persists onboarding completion to settings once the gate settles on skipping the wizard useEffect(() => { if (decision === "wizard" || !hydrated) return; if (persistCompletion && onboardingCompletedAt === null) { @@ -147,6 +148,7 @@ export function FirstRunGate({ } }, [decision, hydrated, onboardingCompletedAt, persistCompletion]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- timer gives up on a pending first-run decision after the timeout useEffect(() => { if (!enabled || decision !== "pending" || !hydrated) return; const timer = window.setTimeout( @@ -159,6 +161,7 @@ export function FirstRunGate({ // Setup opens by itself only from the home route. A direct link, such as // Settings, opens as asked so setup can never lock a user out of the app. const wizardOwnsRoute = pathname === "/"; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- navigates from the home route to /welcome when the gate decides on the wizard useEffect(() => { if (decision === "wizard" && wizardOwnsRoute) { void navigate({ to: "/welcome", replace: true }); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 6dd38d1312756..50d65192c0249 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -128,6 +128,7 @@ export function WelcomeWizard({ const finishingPromiseRef = useRef | null>(null); const completionErrorToastIdRef = useRef | null>(null); const primaryEnvironment = usePrimaryEnvironment(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- marks newly listed computers in the shared auto-select registry once and selects the enabled ones useEffect(() => { const newComputers = environments.filter( (environment) => !autoSelectedComputers.has(environment.environmentId), @@ -319,6 +320,7 @@ function ConnectionStep({ .filter((environment) => skippedIds.includes(environment.environmentId)) .map((environment) => environment.label); const continueRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves DOM focus to the Continue button once setup is ready and nothing else holds focus useEffect(() => { if ( ready && @@ -552,6 +554,7 @@ function PairingForm({ const [errorMessage, setErrorMessage] = useState(""); const mountedRef = useRef(true); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tracks mount so a pairing request that settles after unmount does not set state useEffect(() => { mountedRef.current = true; return () => { @@ -718,6 +721,7 @@ function ConnectedAgentsStep({ } | null>(null); const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- asks the environment to refresh its provider list when the environment changes useEffect(() => { void refreshProviders({ environmentId, input: {} }); }, [environmentId, refreshProviders]); @@ -1035,6 +1039,7 @@ function AgentInstallTerminal({ // StrictMode can finish a cancelled open after replacement setup; it must not close or pre-type into that // session. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the setup PTY, types the command into it, and closes it on cleanup useEffect(() => { const generation = setupGenerationRef.current + 1; setupGenerationRef.current = generation; @@ -1197,6 +1202,7 @@ function ImportStep({ ); const importGenerationRef = useRef(0); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- bumps the import generation on mount and unmount so in-flight imports drop stale results useEffect(() => { importGenerationRef.current += 1; return () => { diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx index bbe890d4d5df3..341ea891b18e8 100644 --- a/apps/web/src/components/preview/AgentBrowserCursor.tsx +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -66,6 +66,7 @@ export function AgentCursorMark(props: { const [inactiveSequence, setInactiveSequence] = useState(null); const active = inactiveSequence !== sequence; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- timer fades the agent cursor after each move or click useEffect(() => { const timeout = window.setTimeout(() => setInactiveSequence(sequence), CURSOR_ACTIVE_MS); return () => window.clearTimeout(timeout); diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 7624c3df2222b..51e74bd605ac0 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -79,6 +79,7 @@ function PreviewPanelShellFrame( stopObservingRow(); }; }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- on unmount, returns DOM focus to the panel toggle if removal dropped it on the body useLayoutEffect(() => { const host = hostRef.current; if (!host || !collapsible) return; @@ -92,6 +93,7 @@ function PreviewPanelShellFrame( }); }; }, [collapsible, hostRef]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves focus to the toggle and sets inert on the host DOM node before a collapse paints useLayoutEffect(() => { const host = hostRef.current; if (!host) return; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 207e51567c694..8bc3f8f77a6e4 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -192,6 +192,7 @@ export function PreviewView({ const serverToolbarShown = snapshot?.runtime === "server" && (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT)._tag !== "fill"; const serverBodyRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- ResizeObserver on the server preview body sizes the viewport toolbar useEffect(() => { const element = serverBodyRef.current; if (!element || !serverToolbarShown) return; @@ -202,7 +203,9 @@ export function PreviewView({ const [serverControlledTabId, setServerControlledTabId] = useState(null); const serverInputDisabled = isServerTab && serverControlledTabId !== runtimeTabId; const [serverFrameTabId, setServerFrameTabId] = useState(null); - const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null; + // A desktop page that mounted before its tab went headless leaves its stale state here. + const desktopOverlay = + tabId && !isServerTab ? (previewState.desktopByTabId[tabId] ?? null) : null; const layoutZoomFactor = useBrowserLayoutZoomFactor(desktopOverlay?.zoomFactor ?? 1); const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; const url = navStatus._tag === "Idle" ? "" : navStatus.url; @@ -788,6 +791,7 @@ export function PreviewView({ }).then(finishPick); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells the preview bridge whether an element pick may send annotations useEffect(() => { if (!pickActive || !previewBridge || !runtimeTabId) return; void previewBridge @@ -795,6 +799,7 @@ export function PreviewView({ .catch(() => undefined); }, [canSendAnnotation, pickActive, runtimeTabId]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels an in-progress element pick on the preview bridge when the tab changes or on unmount useEffect(() => { return () => { if (!pickActiveRef.current) return; @@ -831,6 +836,7 @@ export function PreviewView({ } }, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to preview actions while the view is visible useEffect(() => { if (!visible) return; return subscribePreviewAction((action) => onPreviewAction(action)); diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index a5c3525c3c930..edfdc2bccbce3 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -440,6 +440,7 @@ function MiniPlayerShell({ const [pillOpen, setPillOpen] = useState(false); const handleTappedRef = useRef(false); const handleRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- document pointerdown listener closes the open pill on an outside press useEffect(() => { if (!pillOpen) return; const dismiss = (event: PointerEvent) => { diff --git a/apps/web/src/components/preview/ZoomIndicator.tsx b/apps/web/src/components/preview/ZoomIndicator.tsx index 7988bf44c55c8..2ee754304430c 100644 --- a/apps/web/src/components/preview/ZoomIndicator.tsx +++ b/apps/web/src/components/preview/ZoomIndicator.tsx @@ -14,6 +14,7 @@ export function ZoomIndicator({ zoomFactor }: Props) { const lastFactorRef = useRef(zoomFactor); const timerRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- shows the indicator on a zoom change and hides it with a timer useEffect(() => { if (Math.abs(lastFactorRef.current - zoomFactor) < ZOOM_EPSILON) return; lastFactorRef.current = zoomFactor; diff --git a/apps/web/src/components/preview/agentOpenedPreviews.test.ts b/apps/web/src/components/preview/agentOpenedPreviews.test.ts index 9464d832d7f79..f6ad67432acdd 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.test.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.test.ts @@ -258,7 +258,7 @@ describe("followPreviewSessions", () => { useRightPanelStore .getState() .byThreadKey[threadKey]?.surfaces.filter((surface) => surface.kind === "preview") - .map((surface) => surface.id); + .map((surface) => surface.id) ?? []; it("adds visible sessions as tabs and drops tabs whose session ended", () => { setup(); diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 69c6be2046fea..92bc9b29b35c3 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -84,6 +84,7 @@ export function usePreviewBridge(input: { }); }, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to preview bridge state changes and capture activity for the current tab useEffect(() => { if (!previewBridge || typeof window === "undefined") return; lastReportedUrl.current = null; diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 7f44194f98354..8bb765ff5f7d9 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -282,6 +282,7 @@ function ProjectScriptEditorForm({ const pendingSubmissionRef = useRef(null); // A save that settles after this form is replaced must not close or error // the request that replaced it. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- drops the pending save on unmount so a late result cannot close or fail the next request useEffect( () => () => { pendingSubmissionRef.current = null; diff --git a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx index 6a5126085162c..24facbea1911f 100644 --- a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx +++ b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx @@ -9,7 +9,7 @@ import { useLocation } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; import { type ReactNode, useEffect, useRef, useState } from "react"; -import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "~/branding"; +import { DESKTOP_DOWNLOAD_URL } from "~/branding"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { isHostedStaticApp } from "~/hostedPairing"; import { useEnvironments } from "~/state/environments"; @@ -104,6 +104,7 @@ function HostedPromptLinkLanding({ const onPhone = /android|iphone|ipad/i.test(navigator.userAgent); const autoOpenedRef = useRef(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- redirects window.location to the app deep link once when the user chose to always open in the app useEffect(() => { if (!openInAppRemembered || !summary.ok || autoOpenedRef.current) return; autoOpenedRef.current = true; @@ -112,21 +113,16 @@ function HostedPromptLinkLanding({ if (!summary.ok) { return ( - - + + ); } const { prompt, details } = summary.value; return ( - + diff --git a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx index 16f1932dd1554..12abbeabd133a 100644 --- a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx +++ b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx @@ -325,6 +325,7 @@ export function NewThreadLinkView({ link }: { readonly link: NewThreadLink }) { } }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- runs each launch decision once: navigates to the thread or opens a draft useEffect(() => { if (launch === null || launchedRef.current === launch) return; launchedRef.current = launch; @@ -394,6 +395,7 @@ function PullRequestLinkCheckout({ }, }); const warnedRef = useRef(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- shows the hidden-characters warning toast once per linked prompt useEffect(() => { if (warnedRef.current || link.prompt === null) return; warnedRef.current = true; diff --git a/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx b/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx index 956df9eed73ad..847ad68a71ec5 100644 --- a/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx +++ b/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx @@ -20,6 +20,7 @@ export function ThreadLinkPrompt({ threadRef }: { readonly threadRef: ScopedThre structuralSharing: true, }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- places a ?prompt link into the composer once per location and strips it from the URL via the router useEffect(() => { // A pending navigation shows the next location while the params still name this thread. if (!isThreadRoutePathname(location.pathname, threadRef)) return; diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 22a244e809ca8..f8d58cfaa020d 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -435,6 +435,7 @@ function PullRequestCodeTab({ isStale: isFileViewedStale, } = filesViewed; const appliedRefreshToken = useRef(refreshToken); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a new refresh token clears loaded slices and refetches the first diff page and viewed files useEffect(() => { if (appliedRefreshToken.current === refreshToken) return; appliedRefreshToken.current = refreshToken; @@ -446,6 +447,7 @@ function PullRequestCodeTab({ refreshFilesViewed(); }, [refreshToken, scopeKey, refreshFirstDiffPage, refreshFilesViewed]); const appliedBackgroundRefreshToken = useRef(backgroundRefreshToken); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a background refresh token refetches the first diff page and viewed files while keeping slices useEffect(() => { if (appliedBackgroundRefreshToken.current === backgroundRefreshToken) return; appliedBackgroundRefreshToken.current = backgroundRefreshToken; @@ -1296,6 +1298,7 @@ function PullRequestCodeTab({ answeredRevealNonce.current = revealRequest.nonce; revealFile(revealRequest.path); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the code view to a requested file once loaded, or pages forward until it arrives useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- A reveal for a file not loaded yet pages forward, which sets the slice cursor. answerReveal(); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index bfc1a8ce744ba..5ad07597458c5 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -440,6 +440,7 @@ export function PullRequestDetailPanel({ scroller: HTMLDivElement; target: number; } | null>(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores the scroller's scrollTop before paint after the header condenses useLayoutEffect(() => { const compensation = compensationRef.current; if (compensation === null) return; @@ -499,6 +500,7 @@ export function PullRequestDetailPanel({ event.stopPropagation(); if (!event.repeat) copyReference(`#${reference.number}`, `${terminology.shortLabel} number`); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window keydown capture listener for the copy-number shortcut useEffect(() => { const onKeyDown = (event: KeyboardEvent) => copyFromShortcut(event); window.addEventListener("keydown", onKeyDown, true); @@ -589,6 +591,7 @@ export function PullRequestDetailPanel({ } } const revalidateActivity = useEffectEvent(() => activityQuery.refresh()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refreshes the activity query after commit when render counts a new detail revision useEffect(() => { if (activityRevalidations > 0) revalidateActivity(); }, [activityRevalidations]); @@ -611,6 +614,7 @@ export function PullRequestDetailPanel({ }; const refreshFromHostOnRequest = useEffectEvent(() => void refreshFromHost()); const appliedForcedToken = useRef(forcedRefreshToken); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a new forced refresh token invalidates the host cache and refetches useEffect(() => { if (appliedForcedToken.current === forcedRefreshToken) return; appliedForcedToken.current = forcedRefreshToken; diff --git a/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx b/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx index 8046653f11012..fef668b3f3466 100644 --- a/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx @@ -78,6 +78,7 @@ export function PullRequestGuideTab({ const guide = data?.guide ?? null; const tourShown = active && guide !== null; const reportTourShown = useEffectEvent(() => onTourShown?.()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells the parent once the loaded guide tour is on screen, which data arrival can cause useEffect(() => { if (tourShown) reportTourShown(); }, [tourShown]); diff --git a/apps/web/src/components/pullRequest/PullRequestsColumn.tsx b/apps/web/src/components/pullRequest/PullRequestsColumn.tsx index 9f8b4b95a6923..93562d834d20f 100644 --- a/apps/web/src/components/pullRequest/PullRequestsColumn.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsColumn.tsx @@ -22,14 +22,12 @@ function ExpandableSearch({ searchValue, open, onOpenChange, - focusToken, onFocusWithin, }: { searchInput: ReactNode; searchValue: string; open: boolean; onOpenChange: (open: boolean) => void; - focusToken: number; /** * An unmount fires no blur, which is the point: whoever unmounted this can still see the reader * was mid-typing and move the focus somewhere that continues the sentence. @@ -37,18 +35,11 @@ function ExpandableSearch({ onFocusWithin?: (focused: boolean) => void; }) { const containerRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the search input DOM node when the topbar search opens useEffect(() => { if (!open) return; containerRef.current?.querySelector("input")?.focus(); }, [open]); - const appliedFocusToken = useRef(focusToken); - useEffect(() => { - if (appliedFocusToken.current === focusToken) return; - appliedFocusToken.current = focusToken; - const input = containerRef.current?.querySelector("input"); - input?.focus(); - input?.select(); - }, [focusToken]); if (open || searchValue.length > 0) { return (
    (null); const [condensed, setCondensed] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- IntersectionObserver on the scroll marker condenses the topbar useEffect(() => { const marker = markerRef.current; if (!marker) return; @@ -134,20 +126,18 @@ export function PullRequestsColumn({ const topbarSearchFocusedRef = useRef(false); const inFlowSearchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); - const [searchFocusToken, setSearchFocusToken] = useState(0); + const topbarSearchRef = useRef(null); const searchExpanded = searchOpen || searchValue.length > 0; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window keydown listener focuses the visible search input on Cmd/Ctrl+F useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; if (event.key.toLowerCase() !== "f" || !(event.metaKey || event.ctrlKey)) return; if (event.altKey || event.shiftKey) return; event.preventDefault(); - if (condensed) { - setSearchOpen(true); - setSearchFocusToken((token) => token + 1); - return; - } - const input = inFlowSearchRef.current?.querySelector("input"); + // A closed topbar search has no input yet; opening it mounts one that focuses itself. + if (condensed) setSearchOpen(true); + const input = (condensed ? topbarSearchRef : inFlowSearchRef).current?.querySelector("input"); input?.focus(); input?.select(); }; @@ -155,6 +145,7 @@ export function PullRequestsColumn({ return () => window.removeEventListener("keydown", onKeyDown); }, [condensed]); if (!condensed && searchOpen) setSearchOpen(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves DOM focus to the in-flow search when the topbar search it was typed in unmounts useEffect(() => { if (condensed) return; if (!topbarSearchFocusedRef.current) return; @@ -217,13 +208,12 @@ export function PullRequestsColumn({ )}
    {condensed ? ( -
    +
    { topbarSearchFocusedRef.current = focused; }} diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 9e4de110fac23..359d86b4fc766 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -765,6 +765,7 @@ export function PullRequestsPage() { statsByRow, pending: statsQuery.isPending, })); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- queues eager stats batches for the rows, reading known stats and visible keys held outside render useEffect(() => { if (statsPolicy !== "eager") return; const { statsByRow: known } = readStatsProgress(); @@ -784,6 +785,7 @@ export function PullRequestsPage() { }, [filterKey, entriesByStatsKey, statsPolicy]); // The observer outlives the rows it watches, so it reads the rows held at the time it fires. const readStatsRows = useEffectEvent(() => entriesByStatsKey); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- IntersectionObserver on rows requests stats for rows that scroll into view useEffect(() => { if (statsPolicy !== "visible" || typeof IntersectionObserver === "undefined") return; visibleStatsKeys.current = { key: filterKey, values: new Set() }; @@ -833,6 +835,7 @@ export function PullRequestsPage() { if (statsObserver.current === observer) statsObserver.current = null; }; }, [filterKey, statsPolicy]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- prunes stats batches to the observer's visible rows once a stats request settles useEffect(() => { if (statsPolicy !== "visible" || statsQuery.isPending) return; const visible = visibleStatsKeys.current; @@ -919,6 +922,7 @@ export function PullRequestsPage() { closeBatch, hostActionsOf, })); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the pointer close-sweep sensor when the list changes or on unmount useEffect( () => () => closeSensorRef.current?.cancel(), // oxlint-disable-next-line react/exhaustive-effect-dependencies -- The pointer sensor lives outside React; a new list under a sweep in progress must end it. @@ -1014,6 +1018,7 @@ export function PullRequestsPage() { if (rightPanelState.isOpen) setMaximized((value) => !value); }; const onGuideShown = () => setMaximized(true); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the URL-linked pull request in the right panel store useEffect(() => { if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); @@ -1426,6 +1431,7 @@ export function PullRequestsPage() { event.stopPropagation(); if (!event.repeat) toggleRightPanelMaximized(); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- document keydown listener for the right panel and copy-reference shortcuts useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || isCommandPaletteOpen()) return; diff --git a/apps/web/src/components/pullRequest/guide/GuideTour.tsx b/apps/web/src/components/pullRequest/guide/GuideTour.tsx index e374bc59b5ef1..81074bef9ce22 100644 --- a/apps/web/src/components/pullRequest/guide/GuideTour.tsx +++ b/apps/web/src/components/pullRequest/guide/GuideTour.tsx @@ -86,6 +86,7 @@ export function GuideTour({ event.preventDefault(); step(event.key === "j" ? 1 : -1); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- document keydown listener for j/k tour stepping while the guide is active useEffect(() => { if (!active) return; const onKeyDown = (event: KeyboardEvent) => stepFromKey(event); diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts index 36451f6eab961..de3d7a1dc528e 100644 --- a/apps/web/src/components/pullRequest/usePullRequestListView.ts +++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts @@ -185,6 +185,7 @@ export function usePullRequestScope(search: PullRequestsSearch) { export function usePullRequestQueryFilters(search: PullRequestsSearch, typedQuery: string) { const [sentQuery, setSentQuery] = useState(typedQuery); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- debounce timer that sends the typed query after SEARCH_DEBOUNCE_MS useEffect(() => { const timer = window.setTimeout(() => setSentQuery(typedQuery), SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(timer); diff --git a/apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts b/apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts index 18b8c0a9c1082..acecaaf6e14de 100644 --- a/apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts +++ b/apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts @@ -50,6 +50,7 @@ export function useThreadLinkedPullRequest(input: { const relinkKeysRef = useRef(new Map()); const relinkWriteRef = useRef(Promise.resolve()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- persists the detected PR to the server thread once per change and retargets the open right panel store useEffect(() => { if (serverThreadRef === null) return; const threadKey = scopedThreadKey(serverThreadRef); diff --git a/apps/web/src/components/settings/AddEnvironmentDialog.tsx b/apps/web/src/components/settings/AddEnvironmentDialog.tsx index b14a6edebec36..22bb624c27533 100644 --- a/apps/web/src/components/settings/AddEnvironmentDialog.tsx +++ b/apps/web/src/components/settings/AddEnvironmentDialog.tsx @@ -124,6 +124,7 @@ export function AddEnvironmentDialog({ // The discovery atom is kept alive across dialog opens, so re-read SSH config // each time the SSH tab is shown; stale hosts stay visible while it refreshes. const refreshDesktopSshHosts = desktopSshHosts.refresh; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- rereads desktop SSH config through the bridge each time the SSH tab is shown useEffect(() => { if (isSshDiscoveryActive) refreshDesktopSshHosts(); }, [isSshDiscoveryActive, refreshDesktopSshHosts]); diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index e0877c8574acc..08722ec2b061e 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -13,7 +13,8 @@ import { MIN_PANEL_ANIMATION_DURATION_MS, } from "@t3tools/contracts/settings"; import { - resolveEnvironmentIdentificationPillLabel, + resolveSidebarStageBackdropVariant, + type SidebarStageBackdropVariant, useEnvironmentStageLabel, } from "../SidebarStageBackdrop"; import { useCustomThemes } from "../../hooks/useCustomThemes"; @@ -35,10 +36,40 @@ import { TypographySection } from "./TypographySettings"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", + "nightly-artwork": "Nightly artwork", + "dev-artwork": "Dev artwork", pill: "Version pill", none: "None", }; +// The default "artwork" follows the running channel, so it is labeled as that channel's art, +// or as "None" on Latest. The other channel's art is offered as a fixed choice. +function environmentIdentificationOptions( + stageVariant: SidebarStageBackdropVariant | null, +): ReadonlyArray { + if (stageVariant === "dev") { + return [ + ["artwork", "Dev artwork"], + ["nightly-artwork", "Nightly artwork"], + ["pill", "Version pill"], + ["none", "None"], + ]; + } + if (stageVariant === "nightly") { + return [ + ["artwork", "Nightly artwork"], + ["dev-artwork", "Dev artwork"], + ["pill", "Version pill"], + ["none", "None"], + ]; + } + return [ + ["artwork", "None"], + ["nightly-artwork", "Nightly artwork"], + ["dev-artwork", "Dev artwork"], + ]; +} + const CHAT_WIDTH_LABELS: Record = { comfortable: "Comfortable", wide: "Wide", @@ -61,8 +92,13 @@ export function AppearanceSettingsPanel() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const environmentStageLabel = useEnvironmentStageLabel(); - const showEnvironmentIdentification = - resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; + const environmentStageVariant = resolveSidebarStageBackdropVariant(environmentStageLabel); + const environmentIdentificationOptionList = + environmentIdentificationOptions(environmentStageVariant); + const environmentIdentificationLabel = + environmentIdentificationOptionList.find( + ([value]) => value === settings.environmentIdentificationMode, + )?.[1] ?? ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode]; const glassOpacityRatio = (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); const glassOpacitySliderStyle = { @@ -316,52 +352,52 @@ export function AppearanceSettingsPanel() { ) : null } /> - {showEnvironmentIdentification ? ( - - updateSettings({ - environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, - }) - } - /> - ) : null - } - control={ - { + const option = environmentIdentificationOptionList.find( + ([optionValue]) => optionValue === value, + ); + if (option) updateSettings({ environmentIdentificationMode: option[0] }); + }} + > + - - - {ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode]} - - - - {Object.entries(ENVIRONMENT_IDENTIFICATION_LABELS).map(([value, label]) => ( - - {label} - - ))} - - - } - /> - ) : null} + {environmentIdentificationLabel} + + + {environmentIdentificationOptionList.map(([value, label]) => ( + + {label} + + ))} + + + } + /> operation.current?.abort(); // Once the card unmounts, nothing may start a sign-in or open a page on its behalf. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- aborts the in-flight setup or sign-in AbortController on unmount useEffect(() => () => operation.current?.abort(), []); const [autoStartHandled, setAutoStartHandled] = useState(false); const installed = installation?.installedVersion != null; @@ -336,6 +337,7 @@ function ManagedCodexSetup({ } // The server does not refresh the provider snapshot when a sign-in flow // succeeds, and the flow can finish elsewhere (callback, pasted redirect URL). + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- asks the server to refresh the provider snapshot once the sign-in flow succeeds useEffect(() => { if (awaitingProvider && auth?.phase === "succeeded") { void refreshProviders({ environmentId, input: { instanceId } }); @@ -405,6 +407,7 @@ function ManagedCodexSetup({ const transferFinishedProfile = useEffectEvent((profile: ChatGptTransferredProfile) => transferProfile(profile), ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- imports the finished handoff profile to the server once per attempt when the handoff query completes useEffect(() => { if (!handoff || handoffQuery.data?.phase !== "finished") return; const attemptId = handoff.input.attemptId; @@ -571,11 +574,13 @@ function ManagedCodexSetup({ onAutoStartConsumed(); void setup(); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- dispatches the automatic setup server command after the auto-start flag commits useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- Starting setup dispatches a server command, which marks itself pending before it awaits. if (autoStartHandled) startAutomatically(); }, [autoStartHandled]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the desktop bridge provider auth callback when the URL changes or the card unmounts useEffect(() => { if (!clientCallback || !url || !window.desktopBridge?.cancelProviderAuthCallback) return; return () => { diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index daa240cbf68f6..06fba368d28f4 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -36,6 +36,8 @@ import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; +import { LinkedEnvironmentsSettings } from "./LinkedEnvironmentsSettings"; +import { usePeerLinkHosts } from "~/state/peerLinks"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; import { PullRequestRoutingSettings } from "./PullRequestRoutingSettings"; import { Input } from "../ui/input"; @@ -156,6 +158,7 @@ export function ConnectionsSettings({ view }: { readonly view: "environments" | const desktopBridge = window.desktopBridge; const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); + const peerLinkHosts = usePeerLinkHosts(); const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { reportFailure: false, @@ -1533,6 +1536,14 @@ export function ConnectionsSettings({ view }: { readonly view: "environments" | }} /> ) : null} + {peerLinkHosts.map((environment, index) => ( + 1 ? environment.label : null} + isSearchTarget={index === 0} + /> + ))} diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index 93edc8d327ce0..3f6d5d6f59b17 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -98,6 +98,7 @@ export function FontFamilyPicker({ // Base UI needs an initial closed commit; mounting open loses exit transitionend and leaves the popup // stuck. const [openAfterMount] = useState(initialOpen); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the Base UI popup after its first closed commit so the entrance transition is recorded useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- Base UI needs one closed commit first to record the popup's entrance style. if (openAfterMount) setOpen(true); diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 7e3833de2b9e9..b06f7b04c4fb8 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -17,6 +17,7 @@ export function LabsSettingsPanel() { {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } + @@ -71,3 +72,35 @@ function GuestLinksRow() { /> ); } + +function LinkedEnvironmentsRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + Let agents here work on your other machines, and theirs here. Turn it on for each machine, + then link them in{" "} + + Connections + + . While it is off, nothing new starts over a link; tasks already running finish. + + } + control={ + + updateSettings({ linkedEnvironmentsEnabled: Boolean(checked) }) + } + aria-label="Linked environments" + /> + } + /> + ); +} diff --git a/apps/web/src/components/settings/LinkAccessPicker.tsx b/apps/web/src/components/settings/LinkAccessPicker.tsx new file mode 100644 index 0000000000000..407f05f934a1c --- /dev/null +++ b/apps/web/src/components/settings/LinkAccessPicker.tsx @@ -0,0 +1,63 @@ +import { AuthMcpClientAccess } from "@t3tools/contracts"; +import { useId } from "react"; + +import { accessConfig } from "../auth/ConnectAgentSurface"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; + +/** What agents here may do in an environment being linked, with what that does not allow. */ +export function LinkAccessPicker({ + value, + onChange, + disabled = false, +}: { + readonly value: AuthMcpClientAccess; + readonly onChange: (access: AuthMcpClientAccess) => void; + readonly disabled?: boolean; +}) { + const labelId = useId(); + const selected = accessConfig[value]; + return ( +
    + + What agents here may do there + + +

    + {selected.description} An agent never gets more access there than its own mode here. Below + full access, it can change only threads this link started, not that machine's own threads, + projects or settings. +

    +
    + ); +} diff --git a/apps/web/src/components/settings/LinkMachineSelect.tsx b/apps/web/src/components/settings/LinkMachineSelect.tsx new file mode 100644 index 0000000000000..9977f0bfc699e --- /dev/null +++ b/apps/web/src/components/settings/LinkMachineSelect.tsx @@ -0,0 +1,87 @@ +import type { LinkableMachine } from "@t3tools/client-runtime/link-request"; +import { useId } from "react"; + +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { Badge } from "../ui/badge"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; + +/** The machine choice for linking by an address typed in. */ +export const ANOTHER_ADDRESS = "another-address"; + +/** Whether a machine is linked already, or how this app knows it when not connected to it. */ +export function LinkMachineBadge({ machine }: { readonly machine: LinkableMachine }) { + if (machine.linked) { + return ( + + Linked + + ); + } + if (machine.connected) return null; + return ( + + {machine.connectOnly ? "T3 Connect" : "Not connected"} + + ); +} + +function MachineName({ machine }: { readonly machine: LinkableMachine }) { + return ( + + + {machine.label} + + ); +} + +/** The user's other machines, including those on their T3 Connect account, then an address. */ +export function LinkMachineSelect({ + machines, + value, + onChange, + disabled, +}: { + readonly machines: ReadonlyArray; + readonly value: string | null; + readonly onChange: (value: string) => void; + readonly disabled: boolean; +}) { + const labelId = useId(); + return ( +
    + + Machine + + +
    + ); +} diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx new file mode 100644 index 0000000000000..3b19489dd1e97 --- /dev/null +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -0,0 +1,358 @@ +import { useAtomValue } from "@effect/atom-react"; +import { linkRequestFailureMessage } from "@t3tools/client-runtime/link-request"; +import { + AuthAccessWriteScope, + type AuthMcpClientAccess, + type EnvironmentId, + type PeerLinkSummary, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { PlusIcon } from "lucide-react"; +import { useId, useRef, useState } from "react"; + +import { requestConfirmDialog } from "~/confirmDialog"; +import { mintPairingCodeOn } from "~/state/peerPairingCode"; +import { peerLinkEnvironment, useLinkableMachines } from "~/state/peerLinks"; +import { useEnvironmentQuery } from "~/state/query"; +import { useEnvironmentScope, usePreparedConnection } from "~/state/session"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { accessConfig } from "../auth/ConnectAgentSurface"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { LinkAccessPicker } from "./LinkAccessPicker"; +import { ANOTHER_ADDRESS, LinkMachineSelect } from "./LinkMachineSelect"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { useNowMinute } from "../../hooks/useNowMinute"; +import { searchableSetting } from "./settingsSearch"; + +const failureToast = (title: string, description: string) => + toastManager.add(stackedThreadToast({ type: "error", title, description })); + +export function LinkedEnvironmentsSettings({ + environmentId, + environmentLabel, + isSearchTarget, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string | null; + readonly isSearchTarget: boolean; +}) { + const links = useEnvironmentQuery(peerLinkEnvironment.list({ environmentId, input: {} })); + const canLink = useAtomValue(peerLinkEnvironment.link.permissionAtom(environmentId)); + const search = searchableSetting("linked-environments"); + return ( + : null} + > + {links.error ? ( + + ) : !links.data ? ( + + ) : links.data.links.length === 0 ? ( + + ) : ( + links.data.links.map((link) => ( + + )) + )} + + ); +} + +function LinkedEnvironmentRow({ + environmentId, + link, +}: { + readonly environmentId: EnvironmentId; + readonly link: PeerLinkSummary; +}) { + const nowMinute = useNowMinute(); + const health = peerLinkHealth(link, DateTime.makeUnsafe(nowMinute)); + const unlink = useAtomCommand(peerLinkEnvironment.unlink, { label: "unlink environment" }); + const canUnlink = useAtomValue(peerLinkEnvironment.unlink.permissionAtom(environmentId)); + const [busy, setBusy] = useState(false); + const forget = async () => { + const confirmed = await (requestConfirmDialog( + `Forget ${link.label}? Agents here stop working there. Revoke "T3 Code · …" in ${link.label}'s Connections to end the session there too.`, + { variant: "destructive" }, + ) ?? Promise.resolve(false)); + if (!confirmed) return; + setBusy(true); + const result = await unlink({ environmentId, input: { environmentId: link.environmentId } }); + setBusy(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast("Could not forget the link", String(squashAtomCommandFailure(result))); + } + }; + const status = + health.kind === "expired" + ? "Expired. Link it again with a new pairing code." + : health.kind === "unreachable" + ? (health.detail ?? "Not answering right now.") + : health.kind === "failing" + ? health.detail + : health.expiresInDays !== null + ? `Expires in ${health.expiresInDays} ${health.expiresInDays === 1 ? "day" : "days"}. Link it again to renew.` + : "Reachable"; + return ( +
    +
    +
    +
    + +

    {link.label}

    +
    +

    + {accessConfig[link.access].label} · {status} +

    +

    {link.urls.join(", ")}

    +
    + {canUnlink ? ( +
    + +
    + ) : null} +
    +
    + ); +} + +function LinkEnvironmentAction({ environmentId }: { readonly environmentId: EnvironmentId }) { + const [open, setOpen] = useState(false); + const machines = useLinkableMachines(environmentId, open); + // Null until the user picks; with no other machines known, only an address is left. + const [choice, setChoice] = useState(null); + const [address, setAddress] = useState(""); + const [pairingCode, setPairingCode] = useState(""); + const [access, setAccess] = useState("approval-required"); + const [busy, setBusy] = useState(false); + // Machines whose minting already failed, so the dialog asks for a code instead. + const [mintFailed, setMintFailed] = useState>(new Set()); + // Enter then a click can both run before a re-render; this guard is synchronous. + const inFlight = useRef(false); + const formId = useId(); + const link = useAtomCommand(peerLinkEnvironment.link, { label: "link environment" }); + const selected = choice ?? (machines.length === 0 ? ANOTHER_ADDRESS : null); + const machine = machines.find((candidate) => candidate.environmentId === selected) ?? null; + // This client can mint a code there when it is connected to that + // environment with a session that may create pairing links. + const targetId = machine?.environmentId ?? null; + const connected = usePreparedConnection(targetId); + const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope); + const canMint = + targetId !== null && Option.isSome(connected) && mayMint && !mintFailed.has(targetId); + const needsAddress = selected === ANOTHER_ADDRESS || machine?.urls.length === 0; + const ready = + selected !== null && + (!needsAddress || address.trim().length > 0) && + (canMint || pairingCode.trim().length > 0); + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) { + setChoice(null); + setAddress(""); + setPairingCode(""); + setAccess("approval-required"); + setMintFailed(new Set()); + } + }; + // Settles to the code to link with, or null after reporting why there is none. + const resolvePairingCode = async () => { + if (!canMint || targetId === null) return pairingCode.trim(); + const minted = await mintPairingCodeOn(targetId, access); + if (Result.isSuccess(minted)) return minted.success; + setMintFailed((failed) => new Set(failed).add(targetId)); + failureToast("Could not link the environment", linkRequestFailureMessage(minted.failure)); + return null; + }; + const submit = async () => { + const [url, ...alternateUrls] = + machine === null || needsAddress ? [address.trim()] : machine.urls; + if (inFlight.current || !ready || url === undefined) return; + inFlight.current = true; + setBusy(true); + const code = await resolvePairingCode(); + const result = + code === null + ? null + : await link({ + environmentId, + input: { + url, + ...(alternateUrls.length > 0 ? { alternateUrls } : {}), + ...(machine === null + ? {} + : { expectedEnvironmentId: machine.environmentId, expectedLabel: machine.label }), + pairingCode: code, + access, + }, + }); + inFlight.current = false; + setBusy(false); + if (result === null) return; + if (result._tag === "Success") { + toastManager.add({ type: "success", title: `Linked ${result.value.label}` }); + handleOpenChange(false); + } else if (!isAtomCommandInterrupted(result)) { + failureToast("Could not link the environment", String(squashAtomCommandFailure(result))); + } + }; + return ( + { + if (!next && busy) return; + handleOpenChange(next); + }} + > + + + Link + + } + /> + + + Link an environment + + Agents here can then launch, message and wait on threads there. It appears in that + environment's Connections, where it can be revoked. + + + +
    { + event.preventDefault(); + void submit(); + }} + > + {machines.length > 0 ? ( + + ) : null} + {machine?.linked ? ( +

    + Already linked. Linking again renews it with the access you pick. +

    + ) : null} + {needsAddress ? ( + + ) : null} + {selected === null || canMint ? null : ( + + )} + + +
    + + }> + Cancel + + + +
    +
    + ); +} diff --git a/apps/web/src/components/settings/ProviderAuthTerminal.tsx b/apps/web/src/components/settings/ProviderAuthTerminal.tsx index b21a364290206..47cace9e67304 100644 --- a/apps/web/src/components/settings/ProviderAuthTerminal.tsx +++ b/apps/web/src/components/settings/ProviderAuthTerminal.tsx @@ -20,6 +20,7 @@ export default function ProviderAuthTerminal({ // The surface captures its callbacks once at creation. const respond = useStableCallback(onResponse); const offset = outputOffset ?? output.length; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes new auth output into the imperative Ghostty terminal surface useEffect(() => { if (!surface) return; const delta = offset - written.current; diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 51d388913ef91..4b044b5d70ea2 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -156,6 +156,7 @@ export function ProviderModelsSection({ : displayModels; // A model added or moved lands in a later commit, so each commit looks for the pending row. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the pending model's DOM row into view once a later commit renders it useEffect(() => { const slug = scrollToSlugRef.current; if (slug === null) return; diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 5a331193bcae0..a0c07c784547a 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -504,6 +504,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b const refreshLinkState = useEffectEvent(primaryCloudLinkState.refresh); // The server rechecks reachability when the tunnel's 30 s readiness poll changes. // Poll while mounted, so a failed read does not stop later ones. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- 30 s interval that refreshes the cloud link reachability while mounted useEffect(() => { const interval = window.setInterval(() => refreshLinkState(), 30_000); return () => window.clearInterval(interval); diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 17efc7f1d77f1..4b43f8ecc6e77 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -156,6 +156,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu const { theme, resolvedTheme } = useTheme(); // The box grows with the font so the whole transcript shows without scrolling. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies the font to the Ghostty preview surface and resizes its DOM box to fit the rows useEffect(() => { if (!surface) return; void surface.setFont(previewTerminalFont(family, size)).then(() => { @@ -169,6 +170,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu }, [surface, family, size]); // Palette changes may preserve light/dark mode; re-read terminal tokens for every theme change. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes theme tokens read from the DOM's computed styles into the Ghostty preview surface useEffect(() => { const mount = mountRef.current; if (!mount || !surface) return; diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index f48a8bea58ebf..5ceff1589c1bf 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -145,6 +145,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { setActiveResultIndex(maxResultIndex); } + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window keydown listener for the find and slash search shortcuts useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.defaultPrevented || event.isComposing) return; @@ -180,6 +181,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { return () => window.removeEventListener("keydown", handleKeyDown); }, []); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the search input DOM node after the compact search opens useEffect(() => { if (compactSearchOpen) searchInputRef.current?.focus(); }, [compactSearchOpen]); diff --git a/apps/web/src/components/settings/TelemetryExportSettings.tsx b/apps/web/src/components/settings/TelemetryExportSettings.tsx index d4aabc92bd414..54856b81dacfa 100644 --- a/apps/web/src/components/settings/TelemetryExportSettings.tsx +++ b/apps/web/src/components/settings/TelemetryExportSettings.tsx @@ -136,6 +136,7 @@ function TelemetryExportForm() { if (check?.result === null) void sendCheck(key, signal, check.url); } }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- sends pending endpoint check requests once the environment can run them useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- State changes only after the response arrives. if (canCheck) sendPendingChecks(); diff --git a/apps/web/src/components/settings/ThemeColorPicker.tsx b/apps/web/src/components/settings/ThemeColorPicker.tsx index 1bf7c23cf7ebc..ef2c81511b6f1 100644 --- a/apps/web/src/components/settings/ThemeColorPicker.tsx +++ b/apps/web/src/components/settings/ThemeColorPicker.tsx @@ -249,6 +249,7 @@ function ThemeColorPicker({ }) { const [committer] = useState(createFrameCommitter); // Unmounting with the popover open (the editor closes mid-drag) never fires onOpenChange. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- flushes the frame committer on unmount, since an open popover never fires onOpenChange useEffect(() => committer.flush, [committer]); return ( { if (!open) return; applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); }, [activeAppearance, colorsByAppearance, open]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores the document theme when the editor closes or unmounts useEffect(() => { if (!open) return; return () => { @@ -510,6 +512,7 @@ export function ThemeEditorPanel({ : []; const selectedHighlightRolesKey = selectedHighlightRoles.join(","); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- highlights role usage in the DOM, refreshed by a MutationObserver and resize and scroll listeners useEffect(() => { clearThemeInspectorHighlights(); if (!open || selectedRole === null || isInspecting) return; @@ -570,6 +573,7 @@ export function ThemeEditorPanel({ }; }, [isInspecting, open, selectedHighlightRolesKey, selectedRole]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- document pointer, click and key listeners that drive the inspect-mode hover overlay useEffect(() => { if (!open || !isInspecting) { clearThemeInspectorHover(); diff --git a/apps/web/src/components/settings/TypographySettings.tsx b/apps/web/src/components/settings/TypographySettings.tsx index 1f813beedea61..a4380a3e0bdc9 100644 --- a/apps/web/src/components/settings/TypographySettings.tsx +++ b/apps/web/src/components/settings/TypographySettings.tsx @@ -328,6 +328,7 @@ export function TypographySection() { // rows without it would make terminal font edits do nothing. The rows follow the target // directly so its row mounts in the first commit; the target clears once handled. const searchTargetId = useSettingsSearchTargetId(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes the advanced typography flag to localStorage when a search target needs it useEffect(() => { if (searchTargetId !== null) revealSettingsSearchTarget(searchTargetId); }, [searchTargetId]); @@ -398,6 +399,7 @@ function FontFamilySettingsRow({ setDraftSettled(true); } const readAdoptedGeneration = useEffectEvent(() => adopted.generation); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the pending commit timer on unmount useEffect( () => () => { if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current.id); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index f10dfbd738c51..9f5fc6ac4adca 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -122,10 +122,37 @@ describe("searchSettings", () => { "auto-settle-inactive-threads", "auto-settle-merged-threads", "days-before-auto-settle", + "linked-environments", ]); expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); + it("finds linked environments on a remote host without local maintenance access", () => { + const availability = { + hasCloudPublicConfig: false, + hasEnvironment: true, + hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, + canManageLocalBackend: false, + localEnvironmentDisabled: true, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, + }; + const results = (hasPeerLinkHost: boolean) => + searchSettings( + "linked environments", + filterAvailableSettingsSearchItems({ ...availability, hasPeerLinkHost }), + ); + expect(results(false).map((item) => item.id)).not.toContain("linked-environments"); + expect(results(true)).toContainEqual( + expect.objectContaining({ + id: "linked-environments", + to: "/settings/connections", + targetId: "linked-environments", + }), + ); + }); + it("finds keybinding commands by label, command id, and default key", () => { expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle"); expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle"); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 8385b7a9b877d..7b0ea4d68c06b 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -66,6 +66,7 @@ export interface SettingsSearchItem { readonly localEnvironmentOnly?: boolean; readonly wslAvailableOnly?: boolean; readonly managedTunnelOnly?: boolean; + readonly peerLinksOnly?: boolean; readonly secondary?: boolean; readonly requiresThreadAutoSettlement?: boolean; } @@ -80,6 +81,7 @@ export interface SettingsSearchAvailability { readonly isWslSettingsRowVisible: boolean; readonly hasThreadAutoSettlement: boolean; readonly managedTunnelActive?: boolean; + readonly hasPeerLinkHost?: boolean; } export const SETTINGS_SECTION_LABELS: Readonly> = { @@ -333,7 +335,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "environment-identification", title: "Environment identification", to: "/settings/appearance", - searchTerms: ["dev nightly artwork pill label hide none"], + searchTerms: ["dev nightly latest channel artwork pill label hide none"], targetId: "appearance-interface", }, { @@ -421,6 +423,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment", searchTerms: ["beta share guest invite link teammate watch t3 connect tunnel"], }, + { + id: "linked-environments-beta", + title: "Linked environments", + to: "/settings/labs", + scope: "environment", + searchTerms: ["beta link another machine vps peer agents work there delegate"], + }, { id: "auto-settle-inactive-threads", title: "Auto-settle inactive threads", @@ -1077,6 +1086,14 @@ export const SETTINGS_SEARCH_ITEMS = [ "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint", ], }, + { + id: "linked-environments", + peerLinksOnly: true, + title: "Linked environments", + to: "/settings/connections", + targetId: "linked-environments", + searchTerms: ["link peer another machine vps agents work there handoff pairing code"], + }, { id: "remote-environments", title: "Environments", @@ -1279,7 +1296,8 @@ export function filterAvailableSettingsSearchItems( (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) && (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement) && - (!item.managedTunnelOnly || availability.managedTunnelActive === true), + (!item.managedTunnelOnly || availability.managedTunnelActive === true) && + (!item.peerLinksOnly || availability.hasPeerLinkHost === true), ); } diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index 20c5703d342b3..712c4d87d7021 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -6,6 +6,7 @@ import { isElectron } from "~/env"; import { isLocalEnvironmentDisabled } from "~/localEnvironment"; import { desktopWslStateAtom } from "~/state/desktopWslState"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { usePeerLinkHosts } from "~/state/peerLinks"; import { useEnvironmentQuery } from "~/state/query"; import { useEnvironmentScope } from "~/state/session"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; @@ -42,6 +43,7 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0; const primaryEnvironmentId = usePrimaryEnvironmentId(); const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope); + const hasPeerLinkHost = usePeerLinkHosts().length > 0; const cloudLinkState = usePrimaryCloudLinkState().data; // Same fallback as the Connections row: older servers imply a tunnel from `linked`. const managedTunnelActive = @@ -66,5 +68,6 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch isWslSettingsRowVisible: wslSettingsRowVisible, hasThreadAutoSettlement, managedTunnelActive, + hasPeerLinkHost, }); } diff --git a/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx b/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx index b59789274b980..787d1843cac55 100644 --- a/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx +++ b/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx @@ -73,6 +73,7 @@ export function useSnapShotShortcutRecorder({ onError(error instanceof Error ? error.message : "Could not start shortcut recording."); } }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases the desktop bridge's SnapShot shortcut suppression on unmount useEffect( () => () => { requests.clear(); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index d7207e3e376c4..ec161e9998548 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -40,7 +40,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ const environmentIdentificationMode = useEnvironmentIdentificationMode(); const backdropVariant = resolveSidebarStageBackdropVariant( stageLabel, - environmentIdentificationMode === "artwork", + environmentIdentificationMode, ); const pillLabel = environmentIdentificationMode === "pill" diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index 5a5e7e4f25d3b..69de2e934c8af 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -111,6 +111,7 @@ export function SidebarProviderUpdatePill() { setDismissAfterExitKey(null); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fallback timer that finishes the exit if the CSS transition never ends useEffect(() => { if (exitingKey === null) return; // A cancelled or skipped CSS transition must not keep a hidden spinner mounted. @@ -118,6 +119,7 @@ export function SidebarProviderUpdatePill() { return () => window.clearTimeout(timeoutId); }, [exitingKey]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- timer that dismisses the pill after it has been visible for dismissAfterVisibleMs useEffect(() => { if (!dismissAfterVisibleMs || !viewKey) { return; diff --git a/apps/web/src/components/sidebar/routeReveal.ts b/apps/web/src/components/sidebar/routeReveal.ts index de2a64ca858c6..df9d45dc9d33e 100644 --- a/apps/web/src/components/sidebar/routeReveal.ts +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -52,16 +52,18 @@ export function resolveSidebarRouteReveal(input: { readonly routeKey: string | null; readonly isSearching: boolean; readonly scopedProjectKeys: ReadonlySet | null; + readonly delegatedUnderParent: ReadonlySet; readonly findThread: (key: string) => T | undefined; }): { readonly pendingKey: string | null; readonly widensScope: boolean } { - const { reveal, routeKey, isSearching, scopedProjectKeys, findThread } = input; + const { reveal, routeKey, isSearching, scopedProjectKeys, delegatedUnderParent, findThread } = + input; const pendingKey = pendingSidebarRouteReveal(reveal, routeKey); // Search results replace the thread list; the reveal waits for it to return. const thread = pendingKey === null || isSearching ? undefined : findThread(pendingKey); const widensScope = thread !== undefined && scopedProjectKeys !== null && - filterSidebarV2VisibleThreads([thread], scopedProjectKeys).length === 0 && - filterSidebarV2VisibleThreads([thread], null).length === 1; + filterSidebarV2VisibleThreads([thread], scopedProjectKeys, delegatedUnderParent).length === 0 && + filterSidebarV2VisibleThreads([thread], null, delegatedUnderParent).length === 1; return { pendingKey, widensScope }; } diff --git a/apps/web/src/components/ui/middle-truncate.tsx b/apps/web/src/components/ui/middle-truncate.tsx index e4b74ec9ced9b..62dd3c58ecce3 100644 --- a/apps/web/src/components/ui/middle-truncate.tsx +++ b/apps/web/src/components/ui/middle-truncate.tsx @@ -19,6 +19,7 @@ export function MiddleTruncate({ const split = splitForMiddleTruncate(value, tail); const headRef = useRef(null); const head = split?.head; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fits the head span's DOM text and refits it through a ResizeObserver useLayoutEffect(() => { const element = headRef.current; // A new head can keep the same box size, so refit here, not only on resize. diff --git a/apps/web/src/components/ui/standalone-page.tsx b/apps/web/src/components/ui/standalone-page.tsx index aa9446442bb03..0881b84c52f35 100644 --- a/apps/web/src/components/ui/standalone-page.tsx +++ b/apps/web/src/components/ui/standalone-page.tsx @@ -1,68 +1,36 @@ import type { ReactNode } from "react"; -const backdropClassNames = { - pairing: - "absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-emerald-500)_14%,transparent),transparent)]", - error: - "absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-red-500)_16%,transparent),transparent)]", - brand: - "absolute inset-x-0 top-0 h-72 bg-[radial-gradient(48rem_20rem_at_top,color-mix(in_srgb,var(--color-blue-500)_12%,transparent),transparent)]", -}; +import { T3Wordmark } from "../T3Wordmark"; -export function StandalonePage({ - tone, - masthead, - children, -}: { - readonly tone: keyof typeof backdropClassNames; - readonly masthead?: ReactNode; - readonly children: ReactNode; -}) { +export function StandalonePage({ children }: { readonly children: ReactNode }) { return ( // The page owns scrolling because root cannot scroll; safe centering keeps oversized cards top-aligned. -
    -
    -
    - {tone === "pairing" ? ( -
    - ) : null} -
    -
    - -
    -
    - {masthead} -
    {children}
    -
    -
    +
    +
    {children}
    ); } export function StandalonePageHeader({ - eyebrow, + context, title, description, }: { - readonly eyebrow: ReactNode; + readonly context?: ReactNode; readonly title: ReactNode; readonly description: ReactNode; }) { return ( <> -

    - {eyebrow} -

    -

    {title}

    -

    {description}

    +
    + + + Code + + {context ? {context} : null} +
    +

    {title}

    +

    {description}

    ); } diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index e476b308fc459..acf7cf11934bf 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -469,6 +469,7 @@ function ThreadToastVisibleAutoDismiss({ toastId: ToastId; dismissAfterVisibleMs: number | undefined; }) { + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- dismiss timer that pauses on visibilitychange, focus and blur so it only counts visible time useEffect(() => { if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return; if (typeof window === "undefined" || typeof document === "undefined") return; @@ -558,6 +559,7 @@ function Toasts({ position }: { position: ToastPosition }) { ); const visibleToastLayout = buildVisibleToastLayout(visibleToasts); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- prunes the module-level remaining-time map of toasts the manager has dropped useEffect(() => { const activeToastIds = new Set(toasts.map((toast) => toast.id)); for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) { diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 98044ed02f45d..1e6fd85f680fc 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -64,6 +64,7 @@ function Tooltip(props: TooltipPrimitive.Root.Props) { const tracker = use(TooltipScrollContext); const localActionsRef = useRef(null); const actionsRef = props.actionsRef ?? localActionsRef; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases this tooltip from the scroll tracker on unmount useEffect(() => () => tracker?.release(actionsRef), [actionsRef, tracker]); if (!tracker) return ; diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 504009d769a97..e34965632b030 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -102,6 +102,7 @@ function paceMarkStyles(timeLeft: number | null) { export function usePaceMark(timeLeft: number | null) { const [bar, setBar] = useState(null); const [roomy, setRoomy] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the bar DOM width before paint and remeasures through a ResizeObserver useLayoutEffect(() => { if (!bar) return; const measure = () => setRoomy(bar.getBoundingClientRect().width >= 6); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index f195a4290ee5d..7157607dabafc 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -252,6 +252,7 @@ function ActiveSegmentPopover({ // The trigger is gone, so the popup has nothing to anchor to: an exit transition would // slide it to the viewport origin. Unmount it outright, which also clears Base UI's // active-trigger bookkeeping so the segment can open again when it comes back. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- closes the popover handle and unmounts the Base UI popup before paint when its trigger is gone useLayoutEffect(() => { if (available) return; handle.close(); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index a26daff189493..8373d3439eb16 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -153,6 +153,7 @@ function UsageModelDialogHost({ readonly onSetPrice: (model: string) => void; }) { const selectedKey = useAtomValue(selectedUsageModelKeyAtom); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the shared selected-model atom on unmount useEffect(() => () => selectUsageModel(null), []); const model = selectedKey === null @@ -289,6 +290,7 @@ export function UsagePage() { if (periodOption && !showingLimits) selectWindow(periodOption.days); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window capture keydown listener for usage metric and period shortcuts useEffect(() => { globalThis.window.addEventListener("keydown", onUsageKeyDown, true); return () => globalThis.window.removeEventListener("keydown", onUsageKeyDown, true); @@ -335,6 +337,7 @@ export function UsagePage() { const autoRefreshLimits = useEffectEvent(() => { void refreshLimits(true); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refreshes provider limits from the server when the connected environment set changes useEffect(() => { if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits(); }, [showingLimits, connectedLimitsEnvironments]); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index c4f958ae772ad..1745be4df897b 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -331,6 +331,7 @@ function UsageChartPlot({ plot.style.setProperty("--usage-tooltip-top", `${top}px`); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- positions the hover tooltip through plot CSS variables before paint and on ResizeObserver useLayoutEffect(() => { if (hoverIndex === null) return; positionTooltip(); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 250c7790e0795..f1551a3a1473c 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -95,7 +95,12 @@ import { } from "./composerDraftStore"; import { waitForDraftHeroTransition } from "./components/chat/draftHeroTransition"; import { insertInlineContextReference } from "./lib/composerContextReferences"; -import { terminalContextReference, threadContextRecord } from "./lib/composerContextRecords"; +import { + buildMessageContext, + environmentContextRecord, + terminalContextReference, + threadContextRecord, +} from "./lib/composerContextRecords"; import { copiedComposerAttachmentFile, rememberCopiedComposerAttachments, @@ -755,6 +760,63 @@ describe("composerDraftStore thread contexts", () => { }); }); +describe("composerDraftStore environment contexts", () => { + const threadId = ThreadId.make("thread-with-machine"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const vps = environmentContextRecord(EnvironmentId.make("environment-vps"), "Hetzner [vps]"); + + beforeEach(resetComposerDraftStore); + + it("mentions a machine once, survives persistence, and sends only its identity", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(threadRef, "Run it on"); + store.addEnvironmentContexts(threadRef, [vps]); + store.addEnvironmentContexts(threadRef, [{ ...vps, label: "renamed" }]); + + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); + expect(draft?.environmentContexts).toEqual([vps]); + expect(vps.label).toBe("Hetzner vps"); + expect(draft?.prompt).toBe( + `Run it on [${vps.label}](t3-context://v1/environment/${vps.contextId}) `, + ); + + const merge = useComposerDraftStore.persist.getOptions().merge!; + const hydrated = merge( + JSON.parse( + JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())), + ), + useComposerDraftStore.getInitialState(), + ); + const restored = hydrated.draftsByThreadKey[scopedThreadKey(threadRef)]; + expect(restored?.environmentContexts).toEqual([vps]); + expect(restored?.prompt).toBe(draft?.prompt); + + expect( + buildMessageContext({ + terminalContexts: [], + reviewComments: [], + previewAnnotations: [], + environmentContexts: restored?.environmentContexts ?? [], + })?.records, + ).toEqual([ + { + version: 1, + kind: "environment", + contextId: vps.contextId, + label: "Hetzner vps", + environmentId: "environment-vps", + }, + ]); + }); + + it("drops the chip with the record and removes an otherwise empty draft", () => { + const store = useComposerDraftStore.getState(); + store.addEnvironmentContexts(threadRef, [vps]); + store.setEnvironmentContexts(threadRef, []); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + }); +}); + describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.make("project-a"); const otherProjectId = ProjectId.make("project-b"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 6ce4d4fd4cce7..f84d135a1c9fe 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -20,6 +20,7 @@ import { type PreviewAnnotationPayload, RuntimeMode, ThreadContextRecord, + EnvironmentContextRecord, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, @@ -80,6 +81,7 @@ import { reviewCommentContextReference, terminalContextReference, threadContextReference, + environmentContextReference, } from "./lib/composerContextRecords"; import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; @@ -99,6 +101,7 @@ const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); const isThreadContextRecord = Schema.is(ThreadContextRecord); +const isEnvironmentContextRecord = Schema.is(EnvironmentContextRecord); const isSnapShotSource = Schema.is(SnapShotSource); const isPreviewAnnotationPayload = Schema.is(PreviewAnnotationPayloadSchema); @@ -239,6 +242,7 @@ const PersistedComposerThreadDraftState = Schema.Struct({ previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), reviewComments: Schema.optionalKey(Schema.Array(ReviewCommentContextSchema)), threadContexts: Schema.optionalKey(Schema.Array(ThreadContextRecord)), + environmentContexts: Schema.optionalKey(Schema.Array(EnvironmentContextRecord)), modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)), activeProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), modelSelectionExplicit: Schema.optionalKey(Schema.Boolean), @@ -362,6 +366,7 @@ export interface ComposerThreadDraftState { previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; threadContexts: ThreadContextRecord[]; + environmentContexts: EnvironmentContextRecord[]; modelSelectionByProvider: Partial>; activeProvider: ProviderInstanceId | null; modelSelectionExplicit?: boolean; @@ -383,7 +388,8 @@ export function composerDraftHasUserContent( draft.terminalContexts.length > 0 || draft.previewAnnotations.length > 0 || draft.reviewComments.length > 0 || - draft.threadContexts.length > 0 + draft.threadContexts.length > 0 || + draft.environmentContexts.length > 0 ); } @@ -608,6 +614,16 @@ interface ComposerDraftStoreState { threadRef: ComposerThreadTarget, records: ReadonlyArray, ) => void; + /** Mentions machines as context; already-mentioned machines are skipped. */ + addEnvironmentContexts: ( + threadRef: ComposerThreadTarget, + records: ReadonlyArray, + options?: ComposerContextAddOptions, + ) => void; + setEnvironmentContexts: ( + threadRef: ComposerThreadTarget, + records: ReadonlyArray, + ) => void; clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( threadRef: ComposerThreadTarget, @@ -723,6 +739,7 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = []; const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = []; const EMPTY_THREAD_CONTEXTS: ThreadContextRecord[] = []; +const EMPTY_ENVIRONMENT_CONTEXTS: EnvironmentContextRecord[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_FILES); Object.freeze(EMPTY_IDS); @@ -730,6 +747,7 @@ Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); Object.freeze(EMPTY_PREVIEW_ANNOTATIONS); Object.freeze(EMPTY_REVIEW_COMMENTS); Object.freeze(EMPTY_THREAD_CONTEXTS); +Object.freeze(EMPTY_ENVIRONMENT_CONTEXTS); const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = Object.freeze({}); const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze({ @@ -747,6 +765,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ previewAnnotations: EMPTY_PREVIEW_ANNOTATIONS, reviewComments: EMPTY_REVIEW_COMMENTS, threadContexts: EMPTY_THREAD_CONTEXTS, + environmentContexts: EMPTY_ENVIRONMENT_CONTEXTS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, @@ -764,6 +783,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { previewAnnotations: [], reviewComments: [], threadContexts: [], + environmentContexts: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -869,6 +889,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.previewAnnotations.length === 0 && draft.reviewComments.length === 0 && draft.threadContexts.length === 0 && + draft.environmentContexts.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && @@ -1769,6 +1790,9 @@ function normalizePersistedDraftsByThreadId( const threadContexts = Array.isArray(draftCandidate.threadContexts) ? draftCandidate.threadContexts.filter(isThreadContextRecord) : []; + const environmentContexts = Array.isArray(draftCandidate.environmentContexts) + ? draftCandidate.environmentContexts.filter(isEnvironmentContextRecord) + : []; const previewAnnotations = Array.isArray(draftCandidate.previewAnnotations) ? draftCandidate.previewAnnotations.filter(isPreviewAnnotationPayload) : []; @@ -1885,6 +1909,7 @@ function normalizePersistedDraftsByThreadId( previewAnnotations.length === 0 && reviewComments.length === 0 && threadContexts.length === 0 && + environmentContexts.length === 0 && !hasModelData && !runtimeMode && !interactionMode @@ -1911,6 +1936,7 @@ function normalizePersistedDraftsByThreadId( ...(previewAnnotations.length > 0 ? { previewAnnotations } : {}), ...(reviewComments.length > 0 ? { reviewComments } : {}), ...(threadContexts.length > 0 ? { threadContexts } : {}), + ...(environmentContexts.length > 0 ? { environmentContexts } : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider), @@ -1934,7 +1960,8 @@ function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraf (draft.terminalContexts?.length ?? 0) > 0 || (draft.previewAnnotations?.length ?? 0) > 0 || (draft.reviewComments?.length ?? 0) > 0 || - (draft.threadContexts?.length ?? 0) > 0 + (draft.threadContexts?.length ?? 0) > 0 || + (draft.environmentContexts?.length ?? 0) > 0 ); } @@ -2013,6 +2040,7 @@ export function partializeComposerDraftStoreState( draft.previewAnnotations.length === 0 && draft.reviewComments.length === 0 && draft.threadContexts.length === 0 && + draft.environmentContexts.length === 0 && !hasModelData && draft.runtimeMode === null && draft.interactionMode === null @@ -2068,6 +2096,9 @@ export function partializeComposerDraftStoreState( ...(draft.threadContexts.length > 0 ? { threadContexts: draft.threadContexts.map((record) => ({ ...record })) } : {}), + ...(draft.environmentContexts.length > 0 + ? { environmentContexts: draft.environmentContexts.map((record) => ({ ...record })) } + : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider( @@ -2327,6 +2358,7 @@ function toHydratedThreadDraft( ...(persistedDraft.reviewComments ?? []).map(reviewCommentContextReference), ...(persistedDraft.previewAnnotations ?? []).map(previewAnnotationContextReference), ...(persistedDraft.threadContexts ?? []).map(threadContextReference), + ...(persistedDraft.environmentContexts ?? []).map(environmentContextReference), ...files.map(fileContextReference), ]), images: hydrateImagesFromPersisted(persistedDraft.attachments), @@ -2342,6 +2374,7 @@ function toHydratedThreadDraft( persistedDraft.previewAnnotations?.map((annotation) => ({ ...annotation })) ?? [], reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [], threadContexts: persistedDraft.threadContexts?.map((record) => ({ ...record })) ?? [], + environmentContexts: persistedDraft.environmentContexts?.map((record) => ({ ...record })) ?? [], modelSelectionByProvider, activeProvider, ...(persistedDraft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), @@ -3850,6 +3883,73 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + addEnvironmentContexts: (threadRef, records, options) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + const existingIds = new Set( + (get().draftsByThreadKey[threadKey]?.environmentContexts ?? []).map( + (record) => record.contextId, + ), + ); + const incoming = records.filter((record) => { + if (existingIds.has(record.contextId)) return false; + existingIds.add(record.contextId); + return true; + }); + if (incoming.length === 0) return; + const references = incoming.map(environmentContextReference); + const placedAtCaret = + options?.appendReference !== false && + options?.insertAtCaret !== false && + (contextInsertionHandlers.get(threadKey)?.(references) ?? false); + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const known = new Set(existing.environmentContexts.map((record) => record.contextId)); + const accepted = incoming.filter((record) => !known.has(record.contextId)); + if (accepted.length === 0) return state; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + prompt: + placedAtCaret || options?.appendReference === false + ? existing.prompt + : ensureInlineContextReferences( + existing.prompt, + accepted.map(environmentContextReference), + ), + environmentContexts: [...existing.environmentContexts, ...accepted], + }, + }, + }; + }); + }, + setEnvironmentContexts: (threadRef, records) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + const environmentContexts = records + .filter(isEnvironmentContextRecord) + .map((record) => ({ ...record })); + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const retainedIds = new Set(environmentContexts.map((record) => record.contextId)); + let prompt = existing.prompt; + for (const previous of existing.environmentContexts) { + if (retainedIds.has(previous.contextId)) continue; + prompt = removeInlineContextReference(prompt, previous.contextId).prompt; + } + prompt = ensureInlineContextReferences( + prompt, + environmentContexts.map(environmentContextReference), + ); + const nextDraft = { ...existing, prompt, environmentContexts }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey]; + else nextDraftsByThreadKey[threadKey] = nextDraft; + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, removeReviewComment: (threadRef, commentId) => { const threadKey = resolveComposerDraftKey(get(), threadRef); if (!threadKey || !commentId) return; @@ -3946,6 +4046,7 @@ const composerDraftStore = create()( previewAnnotations: [], reviewComments: [], threadContexts: [], + environmentContexts: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -3975,6 +4076,7 @@ const composerDraftStore = create()( ...current.terminalContexts.map(terminalContextReference), ...current.reviewComments.map(reviewCommentContextReference), ...current.threadContexts.map(threadContextReference), + ...current.environmentContexts.map(environmentContextReference), ...current.previewAnnotations.map(previewAnnotationContextReference), ]), images: [], diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 4ac7fa26e3cfc..e60ed172e9260 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -151,7 +151,7 @@ const layerWakeups = Wakeups.layer({ ), }); -function clientMetadata() { +export function currentClientMetadata() { return clientPresentationMetadata({ appVersion: APP_VERSION, hosted: isHostedStaticApp(), @@ -325,7 +325,7 @@ const analyticsDevice = { const layerCapabilities = Layer.effectContext( Effect.sync(() => { const presentation = ClientCapabilities.ClientPresentation.of({ - metadata: clientMetadata(), + metadata: currentClientMetadata(), }); const cloudSession = ClientCapabilities.CloudSession.of({ identity: Effect.sync(() => @@ -462,7 +462,7 @@ const loadSecondaryConnectionRegistration = Effect.fn( httpBaseUrl, credential: entry.bootstrapToken, scopes: AuthStandardClientScopes, - clientMetadata: clientMetadata(), + clientMetadata: currentClientMetadata(), }).pipe(Effect.mapError(mapRemoteEnvironmentError)); // Desktop operations need the pool backend id to reach the owning instance; RPC uses the environment id. const connectionId = desktopLocalConnectionId(entry.id); diff --git a/apps/web/src/hooks/FileMetadataThreadProvider.tsx b/apps/web/src/hooks/FileMetadataThreadProvider.tsx index 77b082d1f5435..c85f8e693eead 100644 --- a/apps/web/src/hooks/FileMetadataThreadProvider.tsx +++ b/apps/web/src/hooks/FileMetadataThreadProvider.tsx @@ -29,6 +29,7 @@ export function FileMetadataThreadProvider(props: { checkpoints: OrchestrationV2ThreadProjection["checkpoints"]; } | null>(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- invalidates the server file metadata cache for files whose checkpoints changed useEffect(() => { if (!threadRef || !cwd || !checkpoints) { previous.current = null; diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 2ca31031efcf8..c61fbe13de348 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -232,6 +232,7 @@ export function useCopyToClipboard({ ); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the copied-state reset timer on unmount React.useEffect(() => { return (): void => { if (timeoutIdRef.current) { diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index ac18db8d7c561..195f3c37a075c 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -18,6 +18,8 @@ const testState = vi.hoisted(() => { readonly promotedTo: null; readonly threadId: string; } | null = null; + let openDraftRoute: { readonly kind: "draft"; readonly draftId: string } | null = null; + let draftsWithUserText = new Set(); const router = { state: { location: { href: "/" }, @@ -28,14 +30,26 @@ const testState = vi.hoisted(() => { }), }; const draftStore = { - getComposerDraft: vi.fn(() => ({})), + getComposerDraft: vi.fn((draftId: string) => ({ draftId })), getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), - getDraftSession: vi.fn(() => null), + getDraftSession: vi.fn(() => + openDraftRoute === null + ? null + : { + logicalProjectKey: "remote-project", + promotedTo: null, + threadId: "thread-open", + createdAt: "2026-01-01T00:00:00.000Z", + runtimeMode: "full-access", + interactionMode: "default", + }, + ), getDraftThread: vi.fn(() => null), applyStickyState: vi.fn(), setDraftThreadContext: vi.fn(), setLogicalProjectDraftThreadId: vi.fn(), setModelSelection: vi.fn(), + setPrompt: vi.fn(), }; return { @@ -56,6 +70,17 @@ const testState = vi.hoisted(() => { removeProject() { projectRemoved = true; }, + hasUserText: (draft: { readonly draftId?: string } | null | undefined) => + draft?.draftId !== undefined && draftsWithUserText.has(draft.draftId), + giveUserText(draftId: string) { + draftsWithUserText = new Set([...draftsWithUserText, draftId]); + }, + get openDraftRoute() { + return openDraftRoute; + }, + openEmptyDraft(draftId: string) { + openDraftRoute = { kind: "draft", draftId }; + }, get targetSettings() { return targetSettings; }, @@ -67,6 +92,8 @@ const testState = vi.hoisted(() => { }, ) { storedDraft = nextStoredDraft; + openDraftRoute = null; + draftsWithUserText = new Set(); projectRemoved = false; connectedEnvironmentIds = ["environment-primary", "environment-ssh"]; targetSettings = { @@ -79,6 +106,7 @@ const testState = vi.hoisted(() => { router.navigate.mockClear(); draftStore.setDraftThreadContext.mockClear(); draftStore.setLogicalProjectDraftThreadId.mockClear(); + draftStore.setPrompt.mockClear(); projectFileRead = new Promise((resolve) => { completeProjectFileRead = resolve; }); @@ -161,7 +189,8 @@ vi.mock("../composerDraftStore", () => { getState: () => testState.draftStore, }); return { - composerDraftHasUserContent: () => false, + composerDraftHasUserContent: (draft: { readonly draftId?: string } | null | undefined) => + testState.hasUserText(draft), markPromotedDraftThreadByRef: vi.fn(), useComposerDraftStore, }; @@ -208,7 +237,9 @@ vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", })); -vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); +vi.mock("../threadRoutes", () => ({ + resolveThreadRouteTarget: () => testState.openDraftRoute, +})); vi.mock("../uiStateStore", () => ({ legacyProjectCwdPreferenceKey: () => "remote-project", useUiStateStore: () => [], @@ -246,6 +277,25 @@ describe.each([ expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); }); + it("sets the prompt before the draft shows", async () => { + testState.reset(draft); + const pendingOpen = useNewThreadHandler()( + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + { prompt: "Read the playbook. " }, + ); + + testState.completeProjectFileRead(null); + const opened = await pendingOpen; + + expect(testState.draftStore.setPrompt).toHaveBeenCalledWith( + opened?.draftId, + "Read the playbook. ", + ); + expect(testState.draftStore.setPrompt.mock.invocationCallOrder[0]).toBeLessThan( + testState.router.navigate.mock.invocationCallOrder[0] ?? 0, + ); + }); + it("abandons a delayed draft open when the project is removed meanwhile", async () => { testState.reset(draft); const pendingOpen = useNewThreadHandler()( @@ -263,6 +313,44 @@ describe.each([ }); describe("useNewThreadHandler", () => { + it("keeps the text of a draft that gained some while it waited", async () => { + testState.reset(null); + const raced = { + draftId: "draft-raced", + environmentId: "environment-ssh", + promotedTo: null, + threadId: "thread-raced", + }; + testState.draftStore.getDraftSessionByLogicalProjectKey + .mockReturnValueOnce(null) + .mockReturnValueOnce(raced as never); + testState.giveUserText("draft-raced"); + + const opened = await useNewThreadHandler()( + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + { prompt: "Read the playbook. " }, + ); + + expect(opened?.draftId).toBe("draft-raced"); + expect(testState.draftStore.setPrompt).not.toHaveBeenCalled(); + }); + + it("seeds the prompt into the empty draft that is already open", async () => { + testState.reset(null); + testState.openEmptyDraft("draft-open"); + + const opened = await useNewThreadHandler()( + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + { prompt: "Read the playbook. " }, + ); + + expect(opened?.draftId).toBe("draft-open"); + expect(testState.draftStore.setPrompt).toHaveBeenCalledWith( + "draft-open", + "Read the playbook. ", + ); + }); + it("opens the draft without waiting on a project file from an offline machine", async () => { testState.reset(null, { envMode: null, startFromOrigin: false }); testState.disconnect("environment-ssh"); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 90a45540b242f..90990ae563fdc 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -81,6 +81,8 @@ export function createNewThreadHandler(router: AnyRouter) { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; + /** Composer text for the draft, set before it shows. A draft with user text keeps it. */ + prompt?: string; }, ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); @@ -98,7 +100,14 @@ export function createNewThreadHandler(router: AnyRouter) { setDraftThreadContext, setLogicalProjectDraftThreadId, setModelSelection, + setPrompt, } = useComposerDraftStore.getState(); + // Never over text the user wrote: a draft can gain some while this awaits. + const seedPrompt = (draftId: DraftId) => { + if (options?.prompt === undefined) return; + if (composerDraftHasUserContent(getComposerDraft(draftId))) return; + setPrompt(draftId, options.prompt); + }; const requestingRouteHref = router.state.location.href; const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; const currentRouteTarget = getCurrentRouteTarget(); @@ -286,6 +295,7 @@ export function createNewThreadHandler(router: AnyRouter) { draftId: emptyStoredDraftThread.draftId, threadId: emptyStoredDraftThread.threadId, }; + seedPrompt(emptyStoredDraftThread.draftId); const routeTargetAfterWrites = getCurrentRouteTarget(); if ( routeTargetAfterWrites?.kind === "draft" && @@ -324,6 +334,7 @@ export function createNewThreadHandler(router: AnyRouter) { interactionMode: latestActiveDraftThread.interactionMode, ...pickExplicitWorkspaceOptions(options), }); + seedPrompt(currentRouteTarget.draftId); return Promise.resolve({ draftId: currentRouteTarget.draftId, threadId: latestActiveDraftThread.threadId, @@ -352,6 +363,7 @@ export function createNewThreadHandler(router: AnyRouter) { interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), }); + seedPrompt(racedDraft.draftId); await router.navigate({ to: "/draft/$draftId", params: { draftId: racedDraft.draftId }, @@ -379,6 +391,7 @@ export function createNewThreadHandler(router: AnyRouter) { if (modelSelectionOverride) { setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); } + seedPrompt(draftId); await router.navigate({ to: "/draft/$draftId", params: { draftId }, diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts index b355b0a91ba9c..d261a13ad654a 100644 --- a/apps/web/src/hooks/useLiveRefresh.ts +++ b/apps/web/src/hooks/useLiveRefresh.ts @@ -99,6 +99,7 @@ export function useLiveRefresh( const fallbackViewId = useId(); const viewId = key ?? fallbackViewId; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refresh interval tied to page visibility and user interaction listeners useEffect(() => { if (!enabled) return; const read = (now: number) => runRefresh(viewId, now); diff --git a/apps/web/src/hooks/useProactivePanels.ts b/apps/web/src/hooks/useProactivePanels.ts index 488fcf04e33be..60c78a850c25f 100644 --- a/apps/web/src/hooks/useProactivePanels.ts +++ b/apps/web/src/hooks/useProactivePanels.ts @@ -69,6 +69,7 @@ export function useProactivePanels(input: { > | null>(null); const threadKey = threadRef ? scopedThreadKey(threadRef) : null; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens proactive surfaces in the right panel store as runs and linked PRs change useEffect(() => { if (threadKey === null || threadRef === null) { proactivePanelObservationRef.current = null; diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 39585837dfb83..16bb19829135b 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -89,6 +89,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { // Bounds can change mid-drag (sidebar opens, window narrows), and the render // that carries them rewrites the committed width, so re-apply the live pointer position. const { refresh } = drag; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reapplies the live drag pointer position to the DOM before paint when the bounds change useLayoutEffect(() => refresh({ min: minWidth, max: maxWidth }), [refresh, minWidth, maxWidth]); return { diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.ts b/apps/web/src/hooks/useRightPanelSurfaceClose.ts index 6fc0185dbb187..294112b6ce1f9 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.ts +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.ts @@ -88,8 +88,10 @@ export function useRightPanelSurfaceClose(input: { (left, right) => Number(left.id === activeId) - Number(right.id === activeId), ); for (const surface of ordered) { - releaseSurfaceResources(threadRef, surface); + // Releasing a browser drops its session, which prunes the tab automatically; close it + // first so the close stays a user action that hides the panel after the last tab. store.closeSurface(threadRef, surface.id); + releaseSurfaceResources(threadRef, surface); } const nextActiveSurface = selectActiveRightPanelSurface( useRightPanelStore.getState().byThreadKey, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 9da33c507c7e6..0b05de04ce272 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -390,7 +390,8 @@ export function resolveEnvironmentIdentificationMode(input: { paletteThemeAllowsArtwork?: boolean; }): EnvironmentIdentificationMode { if (!input.settingsHydrated) return "none"; - return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork" + const isArtwork = input.mode !== "pill" && input.mode !== "none"; + return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && isArtwork ? "pill" : input.mode; } diff --git a/apps/web/src/hooks/useSidebarProjectGroups.ts b/apps/web/src/hooks/useSidebarProjectGroups.ts index cb1b3b1013265..5f4008aea482d 100644 --- a/apps/web/src/hooks/useSidebarProjectGroups.ts +++ b/apps/web/src/hooks/useSidebarProjectGroups.ts @@ -2,7 +2,7 @@ import { orderItemsByPreferredIds, sortSidebarV2ProjectGroups } from "../compone import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { useEnvironmentIdentities, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useQuietThreadShells } from "../state/entities"; +import { useDelegatedUnderParent, useProjects, useQuietThreadShells } from "../state/entities"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -11,6 +11,7 @@ export function useSidebarProjectGroups() { // Activity order lags a running thread's streaming ticks until its next // real change; reading every tick re-rendered the whole sidebar with it. const threads = useQuietThreadShells(); + const delegatedUnderParent = useDelegatedUnderParent(); const projectOrder = useUiStateStore((store) => store.projectOrder); const sortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const groupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -35,5 +36,5 @@ export function useSidebarProjectGroups() { primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => labels.get(environmentId) ?? null, }); - return sortSidebarV2ProjectGroups(unsortedGroups, threads, sortOrder); + return sortSidebarV2ProjectGroups(unsortedGroups, threads, sortOrder, delegatedUnderParent); } diff --git a/apps/web/src/hooks/useStableCallback.ts b/apps/web/src/hooks/useStableCallback.ts index bae73ed8dde9f..b300259aef764 100644 --- a/apps/web/src/hooks/useStableCallback.ts +++ b/apps/web/src/hooks/useStableCallback.ts @@ -7,6 +7,7 @@ export function useStableCallback, Result>( const latest = useRef(fn); // Insertion effects run before every layout effect in the commit, so a // child's layout effect already calls this render's fn. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- updates the ref during commit, before any layout effect can call the stable callback useInsertionEffect(() => { latest.current = fn; }); diff --git a/apps/web/src/hooks/useStartTriage.ts b/apps/web/src/hooks/useStartTriage.ts new file mode 100644 index 0000000000000..7d6671bf3d22c --- /dev/null +++ b/apps/web/src/hooks/useStartTriage.ts @@ -0,0 +1,107 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { type EnvironmentId, TRIAGE_PROMPT_FILE_NAME } from "@t3tools/contracts"; +import { useRouter } from "@tanstack/react-router"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { currentClientMetadata } from "~/connection/platform"; +import { projectEnvironment } from "~/state/projects"; +import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { writeTextToClipboard } from "./useCopyToClipboard"; +import { useNewThreadHandler } from "./useHandleNewThread"; +import { useScratchProject } from "./useScratchProject"; + +export const TRIAGE_CLI_COMMAND = "npx t3 triage"; + +const copyTriageCliCommand = () => + writeTextToClipboard(TRIAGE_CLI_COMMAND, "command").catch(() => + toastManager.add({ + type: "error", + title: `Could not copy. Type ${TRIAGE_CLI_COMMAND} instead.`, + }), + ); + +/** A draft is a triage draft while its message still links the playbook file. */ +export const isTriagePrompt = (prompt: string) => prompt.includes(`[${TRIAGE_PROMPT_FILE_NAME}](`); + +/** + * In-app `t3 triage`: the server writes the session files and starts cloning + * the matching source, then a No project draft opens with the playbook linked. + * The draft hero explains the rest; the user picks a provider and describes + * the problem. + */ +export function useStartTriage() { + const router = useRouter(); + const { scratchWorkspaceRootFor } = useScratchProject(); + const handleNewThread = useNewThreadHandler(); + const prepare = useAtomCommand(serverEnvironment.prepareTriage, { reportFailure: false }); + const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); + + // With no draft to show, a toast is the only place to point at the CLI. + const fail = (description: string) => + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start triage", + description: `${description} Run ${TRIAGE_CLI_COMMAND} in a terminal on that machine instead.`, + timeout: 0, + actionProps: { children: "Copy command", onClick: () => void copyTriageCliCommand() }, + // "Copy command" is the copy that helps here, not the error text. + data: { hideCopyButton: true }, + }), + ); + const failWith = ( + result: Extract, { readonly _tag: "Failure" }>, + ) => { + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + fail(error instanceof Error ? error.message : "An error occurred."); + }; + + // `routeChanged` tells the steps the user went elsewhere while they waited; + // the draft then stays closed instead of pulling them away. + const openTriageDraft = async (environmentId: EnvironmentId, routeChanged: () => boolean) => { + const prepared = await prepare({ + environmentId, + input: { client: currentClientMetadata() }, + }); + if (prepared._tag === "Failure") return failWith(prepared); + const project = await openScratch({ environmentId, input: {} }); + if (project._tag === "Failure") return failWith(project); + if (routeChanged()) return; + // A null result means the user went elsewhere; only a throw is a failure. + await handleNewThread(scopeProjectRef(project.value.environmentId, project.value.id), { + prompt: prepared.value.prompt, + }).catch(() => fail("T3 Code could not open a new thread.")); + }; + + return async (environmentId: EnvironmentId) => { + if (scratchWorkspaceRootFor(environmentId) === null) { + fail("This machine cannot start threads without a project."); + return; + } + const requestingRouteHref = router.state.location.href; + // The palette is already closed, and a slow network can hold up the source + // lookup, so say something when the draft does not open at once. + let pendingToastId: ReturnType | undefined; + const pendingTimer = setTimeout(() => { + pendingToastId = toastManager.add({ + type: "loading", + title: "Starting triage…", + timeout: 0, + }); + }, 400); + await openTriageDraft( + environmentId, + () => router.state.location.href !== requestingRouteHref, + ).finally(() => { + clearTimeout(pendingTimer); + if (pendingToastId !== undefined) toastManager.close(pendingToastId); + }); + }; +} diff --git a/apps/web/src/hooks/useWorkspaceMutationRefresh.ts b/apps/web/src/hooks/useWorkspaceMutationRefresh.ts index 1617ba500e90d..b31a0773c4ccc 100644 --- a/apps/web/src/hooks/useWorkspaceMutationRefresh.ts +++ b/apps/web/src/hooks/useWorkspaceMutationRefresh.ts @@ -16,6 +16,7 @@ export function useWorkspaceMutationRefresh(input: { const { enabled = true, mutationId, refresh, resourceKey } = input; const handledTokenRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refetches the resource once per new workspace mutation token useEffect(() => { if (!enabled) return; const token = workspaceMutationRefreshToken(resourceKey, mutationId); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 80c16a1b43159..230296091413b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,3 +1,5 @@ +/* Clerk injects its component styles into the clerk layer (clerkAppearance.cssLayerName). */ +@layer theme, base, clerk, components, utilities; @import "tailwindcss"; @custom-variant dark (&:is(.dark, .dark *)); diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index 2e46dccf922a0..c18f9e716378c 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -379,6 +379,32 @@ describe("attachmentUploadQueue", () => { } }); + it("stamps a file that lands in a draft after its upload finished", async () => { + const draftId = DraftId.make("draft-restored-upload"); + const file = makeFile("restored"); + try { + startAttachmentUpload({ environmentId: firstEnvironment, image: file, draftTarget: draftId }); + await Promise.resolve(); + const settled = awaitAttachmentUploads([file.id]); + TestXmlHttpRequest.requests[0]!.complete(); + await settled; + + useComposerDraftStore.getState().addFiles(draftId, [file]); + startAttachmentUpload({ environmentId: firstEnvironment, image: file, draftTarget: draftId }); + + expect(TestXmlHttpRequest.requests).toHaveLength(1); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([ + { + id: file.id, + uploadedAttachmentId: "pending-environment-1-restored.pdf", + uploadEnvironmentId: firstEnvironment, + }, + ]); + } finally { + useComposerDraftStore.getState().clearComposerContent(draftId); + } + }); + it("turns an expired persisted file into a marker that a re-pick can replace", async () => { const draftId = DraftId.make("draft-expired-upload"); const file: ComposerFileAttachment = { diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 85dcbf4f8d496..6477028fe6bad 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -57,6 +57,8 @@ interface UploadJob { stopWatchingConnection: () => void; } +type DraftFileOwner = Pick; + const jobsByImageId = new Map(); const queue: UploadJob[] = []; const activeUploadsByEnvironment = new Map(); @@ -82,7 +84,7 @@ export function readAttachmentUpload(imageId: string): AttachmentUploadState | u return useAttachmentUploadStore.getState().uploadsByImageId[imageId]; } -function resolveCurrentFileDraftTarget(job: UploadJob): ComposerThreadTarget | undefined { +function resolveCurrentFileDraftTarget(job: DraftFileOwner): ComposerThreadTarget | undefined { if (job.draftTarget === undefined || job.image.type !== "file") { return undefined; } @@ -110,7 +112,7 @@ function resolveCurrentFileDraftTarget(job: UploadJob): ComposerThreadTarget | u } /** Persist background completions even when no composer is mounted. */ -function stampDraftFileUpload(job: UploadJob, attachmentId: string): void { +function stampDraftFileUpload(job: DraftFileOwner, attachmentId: string): void { const draftTarget = resolveCurrentFileDraftTarget(job); if (draftTarget === undefined) { return; @@ -402,6 +404,9 @@ export function startAttachmentUpload(input: { const existing = readAttachmentUpload(input.image.id); if (existing?.status === "ready" && existing.environmentId === input.environmentId) { + // A file restored into a draft after its upload finished (stash, failed send) missed the + // completion stamp; stamping here keeps it from re-uploading after a reload. + stampDraftFileUpload(input, existing.attachmentId); return; } if (existing?.status === "failed" && existing.environmentId === input.environmentId) { diff --git a/apps/web/src/lib/composerContextRecords.ts b/apps/web/src/lib/composerContextRecords.ts index aa3a1fb1ea809..a595168637c77 100644 --- a/apps/web/src/lib/composerContextRecords.ts +++ b/apps/web/src/lib/composerContextRecords.ts @@ -9,6 +9,7 @@ import { import type { ComposerContextId, ComposerContextRecord, + EnvironmentContextRecord, EnvironmentId, FileContextRecord, ImageContextRecord, @@ -174,6 +175,26 @@ export function threadContextRecord(ref: ScopedThreadRef, title: string): Thread }; } +/** One record per machine: mentioning the same machine twice reuses the chip. */ +export function environmentContextReference( + record: EnvironmentContextRecord, +): ComposerContextReference { + return { kind: "environment", contextId: record.contextId, label: record.label }; +} + +export function environmentContextRecord( + environmentId: EnvironmentId, + label: string, +): EnvironmentContextRecord { + return { + version: 1, + kind: "environment", + contextId: toKindScopedComposerContextId("environment", environmentId), + label: sanitizeComposerContextLabel(label, "environment"), + environmentId, + }; +} + export function terminalContextRecord(context: TerminalContextDraft): TerminalContextRecord { return { version: 1, @@ -314,6 +335,7 @@ export function buildMessageContext(input: { reviewComments: ReadonlyArray; previewAnnotations: ReadonlyArray; threadContexts?: ReadonlyArray; + environmentContexts?: ReadonlyArray; attachments?: ReadonlyArray; }): OrchestrationMessageContext | undefined { const screenshotAttachmentIds = new Set( @@ -325,6 +347,7 @@ export function buildMessageContext(input: { ...input.terminalContexts.map(terminalContextRecord), ...input.reviewComments.map(reviewCommentContextRecord), ...(input.threadContexts ?? []), + ...(input.environmentContexts ?? []), ...input.previewAnnotations.map((annotation) => previewAnnotationContextRecord(annotation, { screenshotContextId: screenshotAttachmentIds.has(annotation.id) ? annotation.id : undefined, diff --git a/apps/web/src/lib/contextChipAccents.ts b/apps/web/src/lib/contextChipAccents.ts new file mode 100644 index 0000000000000..978c3cf6a04ab --- /dev/null +++ b/apps/web/src/lib/contextChipAccents.ts @@ -0,0 +1,30 @@ +export const CONTEXT_CHIP_ACCENTS = { + image: "oklch(0.62 0.16 16)", + video: "oklch(0.62 0.16 48)", + file: "oklch(0.62 0.136 237)", + mention: "oklch(0.62 0.11 215)", + terminal: "oklch(0.62 0.134 163)", + element: "oklch(0.62 0.134 70)", + "preview-annotation": "oklch(0.62 0.134 70)", + "review-comment": "oklch(0.62 0.16 292)", + "pull-request": "oklch(0.62 0.16 277)", + "pr-open": "oklch(0.62 0.134 163)", + "pr-draft": "oklch(0.62 0.02 259)", + "pr-merged": "oklch(0.62 0.16 292)", + "pr-closed": "oklch(0.62 0.16 16)", + skill: "oklch(0.62 0.16 322)", + thread: "oklch(0.62 0.12 190)", + environment: "oklch(0.62 0.11 145)", + citation: "oklch(0.62 0.16 259)", + "doc-pdf": "oklch(0.62 0.16 24)", + "doc-word": "oklch(0.62 0.136 248)", + "doc-spreadsheet": "oklch(0.62 0.134 148)", + "doc-slides": "oklch(0.62 0.134 56)", + "doc-epub": "oklch(0.62 0.16 291)", +} as const; + +export type ContextChipAccentKind = keyof typeof CONTEXT_CHIP_ACCENTS; + +export function contextChipColorVariable(kind: ContextChipAccentKind): string { + return `--context-chip-${kind}`; +} diff --git a/apps/web/src/lib/triageTarget.test.ts b/apps/web/src/lib/triageTarget.test.ts new file mode 100644 index 0000000000000..52057314dbc74 --- /dev/null +++ b/apps/web/src/lib/triageTarget.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { pickTriageEnvironment } from "./triageTarget"; + +describe("pickTriageEnvironment", () => { + it("skips a primary machine whose server lacks triage", () => { + expect( + pickTriageEnvironment({ + onScreen: null, + primary: "old-primary", + connected: ["old-primary", "new-remote"], + supportsTriage: (environmentId) => environmentId === "new-remote", + }), + ).toBe("new-remote"); + }); + + it("prefers a capable primary over other capable machines", () => { + expect( + pickTriageEnvironment({ + onScreen: null, + primary: "primary", + connected: ["remote", "primary"], + supportsTriage: () => true, + }), + ).toBe("primary"); + }); +}); diff --git a/apps/web/src/lib/triageTarget.ts b/apps/web/src/lib/triageTarget.ts new file mode 100644 index 0000000000000..e7233b5f11ac4 --- /dev/null +++ b/apps/web/src/lib/triageTarget.ts @@ -0,0 +1,16 @@ +/** + * The machine triage runs on: the one on screen, else the primary if it + * supports triage, else the first connected machine that does. Without No + * project threads the action still shows and explains the terminal fallback, + * so this does not check for them. + */ +export function pickTriageEnvironment(input: { + readonly onScreen: Id | null; + readonly primary: Id | null; + readonly connected: ReadonlyArray; + readonly supportsTriage: (environmentId: Id) => boolean; +}): Id | null { + if (input.onScreen !== null) return input.onScreen; + if (input.primary !== null && input.supportsTriage(input.primary)) return input.primary; + return input.connected.find(input.supportsTriage) ?? null; +} diff --git a/apps/web/src/panelAnimations.ts b/apps/web/src/panelAnimations.ts index aee2f7a0a6c68..76b521ecb0f28 100644 --- a/apps/web/src/panelAnimations.ts +++ b/apps/web/src/panelAnimations.ts @@ -12,7 +12,7 @@ export function usePanelNavigationSuppression(navigationKey: string): boolean { const [paintedNavigationKey, setPaintedNavigationKey] = useState(null); const suppressed = paintedNavigationKey !== navigationKey; - // oxlint-disable-next-line t3code/react-effect-budget -- Wait two browser frames so the new route paints before panel animations are re-enabled; cancel the frames on change or unmount. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Wait two browser frames so the new route paints before panel animations are re-enabled; cancel the frames on change or unmount. useEffect(() => { if (!suppressed) return; let releaseFrame = 0; @@ -56,7 +56,7 @@ export function usePanelPresence( if (open && !present) setPresent(true); if (!open && !animated && present) setPresent(false); - // oxlint-disable-next-line t3code/react-effect-budget -- Keep the closing panel mounted for its CSS exit transition, then unmount it; the timer is cleared on reopen or unmount. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Keep the closing panel mounted for its CSS exit transition, then unmount it; the timer is cleared on reopen or unmount. useEffect(() => { if (open || !animated) return; const timeout = window.setTimeout(() => setPresent(false), durationMs); diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index 882fbb83a9f23..852ccc26e127e 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -6,6 +6,8 @@ import { import { VIDEO_FILE_EXTENSIONS } from "@t3tools/shared/video"; import { fileIconPathForMimeType } from "@t3tools/shared/mediaPreview"; +import type { ContextChipAccentKind } from "~/lib/contextChipAccents"; + export interface PierreIconResolution { name: string; token?: string; @@ -51,7 +53,7 @@ const T3_FILE_ICON_SPRITE = ` - + @@ -60,7 +62,7 @@ const T3_FILE_ICON_SPRITE = ` - + @@ -69,7 +71,7 @@ const T3_FILE_ICON_SPRITE = ` - + @@ -79,14 +81,14 @@ const T3_FILE_ICON_SPRITE = ` - + - + @@ -244,9 +246,17 @@ export function resolvePierreIconForEntry( : icon; } -export function documentIconColorForFileName(fileName: string) { +const DOCUMENT_CHIP_KINDS: Readonly> = { + "t3-file-icon-pdf": "doc-pdf", + "t3-file-icon-word": "doc-word", + "t3-file-icon-spreadsheet": "doc-spreadsheet", + "t3-file-icon-slides": "doc-slides", + "t3-file-icon-epub": "doc-epub", +}; + +export function documentChipKindForFileName(fileName: string) { const icon = resolvePierreIconForEntry(fileName, "file"); - return icon ? DOCUMENT_ICON_COLORS[icon.name] : undefined; + return icon ? DOCUMENT_CHIP_KINDS[icon.name] : undefined; } export function hasSpecificPierreIconForFileName(fileName: string): boolean { diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts index e59ca6dea5f4d..5eb3698eaa224 100644 --- a/apps/web/src/portDiscoveryState.ts +++ b/apps/web/src/portDiscoveryState.ts @@ -72,6 +72,9 @@ export function useThreadDiscoveredPorts(input: { }): ReadonlyArray { const ports = useDiscoveredPorts(input.environmentId); return input.threadId - ? ports.filter((port) => port.terminal?.threadId === input.threadId) + ? ports.filter( + (port) => + port.terminal?.threadId === input.threadId || port.agent?.threadId === input.threadId, + ) : EMPTY_PORTS; } diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index a29cecac37095..764fad8879bc4 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1181,6 +1181,24 @@ describe("rightPanelStore", () => { }); }); + it("hides the panel when browser reconciliation removes its last session tab", () => { + const store = useRightPanelStore.getState(); + store.openBrowser(refA, "tab-a"); + store.reconcileBrowserSurfaces(refA, []); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: false, + activeSurfaceId: null, + surfaces: [], + }); + + store.openBrowser(refA, "tab-b"); + store.open(refA, "diff"); + store.reconcileBrowserSurfaces(refA, []); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA), + ).toMatchObject({ isOpen: true, activeSurfaceId: "diff" }); + }); + it("moves a surface to a new index and keeps it there through browser reconciliation", () => { const store = useRightPanelStore.getState(); store.openTerminal(refA, "term-1"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 5d4d68264aebd..33c537a3cf942 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -972,8 +972,14 @@ export const useRightPanelStore = create()( (surface) => surface.id === current.activeSurfaceId, ); const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview"); + // A closed session tab was the last surface: hide the panel rather than leave it on + // the empty picker, as a user close would. A dropped `browser:new` placeholder does not count. + const closedLastSessionTab = + surfaces.length === 0 && + current.surfaces.some((surface) => surface.kind === "preview" && surface.resourceId); return { ...current, + isOpen: closedLastSessionTab ? false : current.isOpen, surfaces, activeSurfaceId: activeStillExists ? current.activeSurfaceId diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 7444b3623dc57..c6ae748f1e3cb 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -143,6 +143,8 @@ function RootRouteBranch() { const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; + // A sync IPC read; the setting only changes across a relaunch. + const [localEnvironmentDisabled] = useState(isLocalEnvironmentDisabled); // Once true, stays true: a visit to /welcome means the bootstrap already chose a route. const [returningFromWelcome, setReturningFromWelcome] = useState(pathname === "/welcome"); if (pathname === "/welcome" && !returningFromWelcome) setReturningFromWelcome(true); @@ -195,7 +197,9 @@ function RootRouteBranch() { hostedStatic={authGateState.status === "hosted-static"} > {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated || localEnvironmentDisabled ? ( + + ) : null} {isElectron ? : null} @@ -226,12 +230,8 @@ function RootRouteErrorView({ error }: ErrorComponentProps) { const report = errorReport(error, pathname); return ( - - + +