diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9bc321dac0de..38a764eab6d7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,7 @@ body: attributes: value: | Use this form for broken behavior, regressions, crashes, or reliability problems. + Feature requests belong in [Discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas). Search existing issues first and keep the report focused on one problem. - type: checkboxes diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..4f4940ba6655 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Feature request + url: https://github.com/pingdotgg/t3code/discussions/categories/ideas + about: Suggest an improvement or new capability in Discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 3c9424fb322c..000000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Feature request -description: Propose a scoped improvement or new capability. -title: "[Feature]: " -labels: - - enhancement - - needs-triage -body: - - type: markdown - attributes: - value: | - Use this form for new capabilities or meaningful improvements to existing behavior. - This repo is still early. Small, concrete requests that clearly explain the problem and scope are much easier to evaluate. - - - type: checkboxes - id: checks - attributes: - label: Before submitting - options: - - label: I searched existing issues and did not find a duplicate. - required: true - - label: I am describing a concrete problem or use case, not just a vague idea. - required: true - - - type: dropdown - id: area - attributes: - label: Area - description: Which part of the project would this change affect? - options: - - apps/web - - apps/server - - apps/desktop - - apps/mobile - - packages/contracts or packages/shared - - Build, CI, or release tooling - - Docs - - Not sure - validations: - required: true - - - type: textarea - id: problem - attributes: - label: Problem or use case - description: What are you trying to do? What is hard, slow, or impossible today? - placeholder: I want to reconnect to an existing provider session after a browser refresh without losing the current thread state. - validations: - required: true - - - type: textarea - id: proposal - attributes: - label: Proposed solution - description: Describe the behavior, API, or UX you want. - placeholder: Persist enough session metadata so the client can discover and reattach to the active provider session on load. - validations: - required: true - - - type: textarea - id: value - attributes: - label: Why this matters - description: Who benefits, and what outcome does this unlock? - placeholder: This would make reconnects predictable during network drops and reduce accidental duplicate sessions. - validations: - required: true - - - type: textarea - id: scope - attributes: - label: Smallest useful scope - description: What is the narrowest version of this request that would still solve your problem? - placeholder: A first pass only needs to support restoring the active session for the current thread. - validations: - required: true - - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: Workarounds, prior art, or other approaches you considered. - placeholder: I currently work around this by manually restarting the provider session, but that loses in-flight context. - - - type: textarea - id: tradeoffs - attributes: - label: Risks or tradeoffs - description: What costs, complexity, or edge cases should be considered? - placeholder: This may require careful handling when the underlying provider session has already exited. - - - type: textarea - id: references - attributes: - label: Examples or references - description: Links, screenshots, mockups, or comparable tools. - - - type: checkboxes - id: contribution - attributes: - label: Contribution - options: - - label: I would be open to helping implement this. diff --git a/.github/pr-assets/6424-after.svg b/.github/pr-assets/6424-after.svg new file mode 100644 index 000000000000..dbeb594a09da --- /dev/null +++ b/.github/pr-assets/6424-after.svg @@ -0,0 +1 @@ + diff --git a/.github/pr-assets/6424-before.svg b/.github/pr-assets/6424-before.svg new file mode 100644 index 000000000000..6b365bad6e69 --- /dev/null +++ b/.github/pr-assets/6424-before.svg @@ -0,0 +1 @@ + diff --git a/.github/pr-assets/6503-after.svg b/.github/pr-assets/6503-after.svg new file mode 100644 index 000000000000..db1c9cb54065 --- /dev/null +++ b/.github/pr-assets/6503-after.svg @@ -0,0 +1 @@ + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bfce586a5a11..ed9fdf3bfa8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,9 @@ We are not actively accepting contributions right now. -You can still open an issue or PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it. +You can still report a bug or open a PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it. + +Feature requests and proposals belong in [Ideas discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas), not issues. If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control. @@ -50,9 +52,9 @@ If the change depends on motion, timing, transitions, or interaction details, in If we have to guess what changed, we are much less likely to review it. -## Issues First +## Discuss Changes First -If you are thinking about a non-trivial change, open an issue first. +If you are thinking about a non-trivial change, start a discussion first. Issues are reserved for bug reports. That still does not mean we will want the PR, but it gives you a chance to avoid wasting your time. diff --git a/README.md b/README.md index a7264ef62e97..8ec101387f67 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ Checkout their getting started guide for more information: https://viteplus.dev/ vp i ``` -Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. +Read [CONTRIBUTING.md](./CONTRIBUTING.md) before reporting a bug or opening a PR. + +Have a feature request? Start an [Ideas discussion](https://github.com/pingdotgg/t3code/discussions/categories/ideas). Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 38bd6d73e063..2a43c35ba97d 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -40,6 +40,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 62839e6183e7..2c4d16ca8aae 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -21,6 +21,7 @@ describe("DesktopLifecycle", () => { const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 4bf730e84c9c..77a0ff44d63b 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -730,6 +730,84 @@ describe("DesktopBackendManager", () => { ), ); + it.effect( + "re-probes readiness after the first budget expires while the backend is still alive", + () => + Effect.scoped( + Effect.gen(function* () { + const requestUrls: Array = []; + let requestCount = 0; + let readyCount = 0; + let readinessTimeoutCount = 0; + const firstProbe = yield* Deferred.make(); + const childExit = yield* Deferred.make(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(childExit).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }), + ), + ), + ); + + // The backend stays 503 through the first *two* readiness budgets + // and only becomes healthy (200) for the third round, i.e. it comes + // up well after the initial 50ms budget has expired. + const httpLayer = httpClientLayer((request) => + Effect.gen(function* () { + requestCount += 1; + requestUrls.push(request.url); + yield* Deferred.succeed(firstProbe, void 0); + return responseForRequest(request, requestCount <= 2 ? 503 : 200); + }), + ); + + const runFiber = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + readinessTimeout: Duration.millis(50), + onReady: () => + Effect.sync(() => { + readyCount += 1; + }), + onReadinessFailure: () => + Effect.sync(() => { + readinessTimeoutCount += 1; + }), + }).pipe(Effect.provide(Layer.merge(spawnerLayer, httpLayer)), Effect.forkChild); + + yield* Deferred.await(firstProbe); + assert.equal(readyCount, 0); + assert.equal(readinessTimeoutCount, 0); + + // The first 50ms readiness budget expires while the backend still + // answers 503. The child is alive and may yet become healthy, so the + // probe must start a fresh round instead of stopping permanently — + // the pre-fix behavior left the app stuck on "Connecting to WSL…" + // forever even though the backend kept running. + yield* TestClock.adjust(Duration.millis(50)); + assert.equal(readinessTimeoutCount, 1); + assert.equal(readyCount, 0); + + // The second budget also expires (backend still 503), then the third + // round connects. The point is the probe persisted across budgets + // while the process was alive instead of giving up after the first. + yield* TestClock.adjust(Duration.millis(100)); + assert.equal(readinessTimeoutCount, 2); + assert.equal(readyCount, 1); + assert.equal(requestUrls.length, 3); + + yield* Deferred.succeed(childExit, void 0); + assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 0); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + it.effect("starts the configured backend and closes the scoped process on stop", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index f0e5d2e610e1..61e2200794ef 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -592,20 +592,33 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ).pipe(Effect.forkScoped), ); } - yield* waitForHttpReady({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - }).pipe( - Effect.tap(() => options.onReady?.() ?? Effect.void), - Effect.catchTags({ - BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, - }), - Effect.forkScoped, + // Probe readiness in a loop while the backend process is still alive + // instead of giving up after the first budget. A slow cold boot (the + // WSL bundle loading across /mnt/c, or a first launch right after an + // update) can exceed the initial readiness budget while the backend is + // about to come up moments later; a one-shot probe left the app stuck + // on "Connecting to WSL…" forever even though the backend kept running + // and became healthy. Each round gets a fresh budget, and the forked + // loop is torn down with the run scope once the child exits. + const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => + waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }).pipe( + Effect.flatMap(() => options.onReady?.() ?? Effect.void), + Effect.as(true), + Effect.catchTags({ + BackendReadinessTimeoutError: (error) => + (options.onReadinessFailure?.(error) ?? Effect.void).pipe(Effect.as(false)), + }), + ), ); + yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped); + const exit = yield* handle.exitCode.pipe( Effect.mapError( (cause) => diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 783a8ace5d69..5d7f7458551f 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -8,6 +8,7 @@ const { autoUpdaterRemoveListenerMock, exitMock, getAppPathMock, + getSystemLocaleMock, getVersionMock, isDefaultProtocolClientMock, onMock, @@ -30,6 +31,7 @@ const { autoUpdaterRemoveListenerMock: vi.fn(), exitMock: vi.fn(), getAppPathMock: vi.fn(() => "/app"), + getSystemLocaleMock: vi.fn(() => "en-GB"), getVersionMock: vi.fn(() => "1.2.3"), isDefaultProtocolClientMock: vi.fn(() => false), onMock: vi.fn(), @@ -62,6 +64,7 @@ vi.mock("electron", () => ({ setIcon: setDockIconMock, }, getAppPath: getAppPathMock, + getSystemLocale: getSystemLocaleMock, getVersion: getVersionMock, isDefaultProtocolClient: isDefaultProtocolClientMock, isPackaged: true, @@ -115,6 +118,23 @@ describe("ElectronApp", () => { }).pipe(Effect.provide(ElectronApp.layer)), ); + it.effect("reads the OS locale through the service", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + + assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("normalizes POSIX-style locale identifiers that Intl rejects", () => + Effect.gen(function* () { + getSystemLocaleMock.mockImplementationOnce(() => "en_GB"); + const electronApp = yield* ElectronApp.ElectronApp; + + assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + it.effect("reports which app metadata property failed", () => Effect.gen(function* () { const cause = new Error("version unavailable"); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 56c06fdff70f..442ddff5a1bf 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -43,6 +43,13 @@ export class ElectronApp extends Context.Service< { readonly metadata: Effect.Effect; readonly name: Effect.Effect; + /** + * The OS locale, read from the operating system rather than from Chromium's + * resolved application locale — the packaged app ships only the `en-US` + * locale pak, so `app.getLocale()` and the renderer's `Intl` default are + * pinned to `en-US` however the machine is configured. + */ + readonly systemLocale: Effect.Effect; readonly whenReady: Effect.Effect; readonly quit: Effect.Effect; readonly exit: (code: number) => Effect.Effect; @@ -120,6 +127,10 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), + // macOS derives this from NSLocale, which uses POSIX-style identifiers + // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so + // the tag is normalized here rather than in the renderer that consumes it. + systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; yield* Effect.tryPromise({ diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8f11d9b4fd70..69adef9f1827 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -34,6 +34,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getSystemLocale, getWindowFullscreenState, openExternal, probeRemoteEditors, @@ -55,6 +56,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index b6ba65907502..3799a6619e2c 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,7 @@ export const RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL = "desktop:resolve-open-wit export const OPEN_WITH_CHANNEL = "desktop:open-with"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; @@ -17,6 +18,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 16f7a4694afa..0c7e90b95072 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -26,6 +26,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; @@ -64,6 +65,15 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL, + result: Schema.String, + handler: Effect.fn("desktop.ipc.window.getSystemLocale")(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + return yield* electronApp.systemLocale; + }), +}); + export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, result: Schema.Boolean, diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 3e07ae7f39bf..38435e286fa7 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; import * as DesktopState from "../../app/DesktopState.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( ElectronTheme.ElectronTheme, ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), ), + Layer.succeed( + ElectronDialog.ElectronDialog, + ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]), + ), + Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]), + ), + DesktopClientSettings.layerTest(), ); describe("WSL IPC", () => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index cae8c889702a..854c22136070 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getSystemLocale: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); + return typeof result === "string" ? result : null; + }, getLocalEnvironmentBootstraps: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); if (!Array.isArray(result)) { @@ -121,6 +125,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + onQuitShortcut: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { + if (state !== "down" && state !== "up") return; + listener(state); + }; + + ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); + }; + }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, onWindowFullscreenStateChange: (listener) => { diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 00616c6a4761..e63597b71efc 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; +export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index c24dca802c58..c4297a69c260 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -979,7 +979,10 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => + // The guest reports whatever zoom level Chromium handed it from the app + // window, so the tab's own zoom is the source of truth in both directions: + // asserted onto every guest, never read back off one. + effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () => withManager((manager) => Effect.gen(function* () { let effectiveZoom = 0.9; @@ -1025,18 +1028,13 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_zoom"); yield* manager.registerWebview("tab_zoom", 42); - expect(states.at(-1)?.zoomFactor).toBe(0.9); - expect(setZoomFactor).not.toHaveBeenCalled(); + expect(states.at(-1)?.zoomFactor).toBe(1); + expect(setZoomFactor).toHaveBeenCalledWith(1); - effectiveZoom = 1.25; - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; - - expect(states.at(-1)?.zoomFactor).toBe(1.25); - expect(setZoomFactor).not.toHaveBeenCalled(); - - zoomReadable = false; - url = "https://example.com/after-zoom-read-failed"; + // An app zoom leaves the guest reporting the inherited level. Navigating + // must not adopt it as the preview's zoom. + effectiveZoom = 0.8; + url = "https://example.com/after-app-zoom"; listeners.get("did-navigate")?.(); yield* Effect.yieldNow; @@ -1045,7 +1043,18 @@ describe("PreviewManager", () => { url, title: "Example", }); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(states.at(-1)?.zoomFactor).toBe(1); + + // Only the preview's own zoom controls move it. + yield* manager.zoomIn("tab_zoom"); + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.at(-1)?.zoomFactor).toBe(1.1); + + zoomReadable = false; + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + + expect(states.at(-1)?.zoomFactor).toBe(1.1); const replacementSetZoomFactor = vi.fn(); fromId.mockReturnValue({ @@ -1074,8 +1083,103 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_zoom", 43); - expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.at(-1)?.zoomFactor).toBe(1.1); + }), + ), + ); + + // Zooming the app UI pushes the window's zoom level onto every guest, so the + // preview has to be put back at the zoom the user gave it. + effectIt.effect("re-applies each tab's own zoom when the app window zooms", () => + withManager((manager) => + Effect.gen(function* () { + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_reapply"); + yield* manager.registerWebview("tab_reapply", 42); + yield* manager.zoomIn("tab_reapply"); + setZoomFactor.mockClear(); + + yield* manager.reapplyZoom(); + + expect(setZoomFactor).toHaveBeenCalledTimes(1); + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + }), + ), + ); + + // did-attach and dom-ready both re-register the guest that is already + // attached, and a guest that just inherited the app window's zoom needs its + // own back — without that round trip republishing tab state. + effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () => + withManager((manager) => + Effect.gen(function* () { + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + + yield* manager.createTab("tab_reregister_zoom"); + yield* manager.registerWebview("tab_reregister_zoom", 42); + yield* manager.zoomIn("tab_reregister_zoom"); + setZoomFactor.mockClear(); + const publishedBefore = states.length; + + yield* manager.registerWebview("tab_reregister_zoom", 42); + + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.length).toBe(publishedBefore); + expect(states.at(-1)?.zoomFactor).toBe(1.1); }), ), ); @@ -2135,6 +2239,69 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => + withManager((manager) => + Effect.gen(function* () { + let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined; + const goBack = vi.fn(); + const goForward = vi.fn(); + let canGoBack = true; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof mouseNavigate) => { + if (channel === "preview:mouse-navigate") mouseNavigate = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { + canGoBack: () => canGoBack, + canGoForward: () => true, + goBack, + goForward, + }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_nav"); + yield* manager.registerWebview("tab_nav", 42); + expect(mouseNavigate).toBeDefined(); + + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + + mouseNavigate?.({}, { direction: "forward" }); + yield* Effect.yieldNow; + expect(goForward).toHaveBeenCalledOnce(); + + // Ignores unknown payloads and never navigates when history is exhausted. + mouseNavigate?.({}, { direction: "sideways" }); + canGoBack = false; + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("reveals only files inside the configured browser artifact directory", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 5d4dce17e199..9c680abb64da 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -58,6 +58,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; @@ -649,6 +650,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (Option.isSome(next)) yield* emit(tabId, next.value); }); + /** + * Pushes a tab's zoom factor onto whichever guest it currently owns, reading + * both at call time. Anything that applies zoom after an await goes through + * here: a snapshot taken before the await can be older than a zoom action that + * landed in between, and re-applying it would roll that action back. + */ + const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || tab.webContentsId == null) return; + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) return; + yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () => + wc.setZoomFactor(tab.zoomFactor), + ).pipe(Effect.ignore); + }); + const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( tabId: string, ) { @@ -1307,10 +1324,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function confirmedNavigation = false, ) { if (wc.isDestroyed()) return; - const zoomFactor = yield* attempt( - { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id }, - () => wc.getZoomFactor(), - ).pipe(Effect.option); const computedNavStatus = computeNavStatus(wc); const canGoBack = wc.navigationHistory.canGoBack(); const canGoForward = wc.navigationHistory.canGoForward(); @@ -1340,7 +1353,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus, canGoBack, canGoForward, - ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}), + // zoomFactor is deliberately not read back from the guest: Chromium + // reports the level it inherited from the app window, so mirroring it + // would turn an app zoom into the preview's own zoom. updatedAt, }; return [ @@ -1494,6 +1509,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; + const mouseNavigate = (_event: unknown, payload?: unknown): void => { + const direction = + typeof payload === "object" && payload !== null && "direction" in payload + ? (payload as { direction?: unknown }).direction + : undefined; + if (direction !== "back" && direction !== "forward") return; + runFork( + attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { + if (direction === "back") { + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + } else if (wc.navigationHistory.canGoForward()) { + wc.navigationHistory.goForward(); + } + }).pipe(Effect.ignore), + ); + }; const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( event: Electron.Event, input: Electron.Input, @@ -1540,6 +1571,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("did-fail-load", failed as never); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { @@ -1553,6 +1585,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler(({ url }) => { runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => @@ -1718,11 +1751,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); const currentAttachment = attached.get(webContentsId); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { - const zoomFactor = yield* attempt( - { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, - () => wc.getZoomFactor(), - ); - yield* update(tabId, { zoomFactor }); + // The guest we already own re-announced itself, so nothing about the tab + // changed. Only push its zoom back down — Chromium may have just handed + // this guest the app window's zoom level. + yield* assertTabZoom(tabId); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); @@ -1751,18 +1783,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewTabNotFoundError({ tabId }); } - const zoomFactor = - replacedWebContentsId !== null - ? yield* attempt( - { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, - () => { - wc.setZoomFactor(currentTab.zoomFactor); - return currentTab.zoomFactor; - }, - ) - : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => - wc.getZoomFactor(), - ); + // Always assert the tab's own zoom rather than reading the guest's: a guest + // attaching while the app UI is zoomed starts at the embedder's inherited + // zoom level, which is not the preview's zoom. Done before the guest is + // published so it never paints a frame at the inherited zoom. + yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => + wc.setZoomFactor(currentTab.zoomFactor), + ); yield* attachListeners(tabId, wc); const registeredAt = yield* currentIso; const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => @@ -1786,7 +1813,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward(), - zoomFactor, updatedAt: registeredAt, }; return [ @@ -1808,6 +1834,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; + // A zoom action that landed while this attach was in flight addressed the + // guest this one replaced, so settle the new guest on the committed factor. + yield* assertTabZoom(tabId); runFork(restoreControlSession(tabId, wc)); yield* emit(tabId, registered); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => @@ -2101,6 +2130,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + /** + * Chromium hands every guest `` the embedder's zoom level, so zooming + * the app UI drags the previewed page along with it. The preview browser owns + * its own zoom factor, so re-assert it on each attached guest whenever the main + * window's zoom changes (see DesktopWindow.zoomMain). + */ + const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () { + const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys()); + yield* Effect.forEach(tabIds, assertTabZoom, { discard: true }); + }); + const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( tabId: string, transform: (current: number) => number, @@ -3478,6 +3518,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + reapplyZoom, refresh, registerWebview, resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), @@ -3776,6 +3817,9 @@ export class PreviewManager extends Context.Service< readonly zoomIn: (tabId: string) => Effect.Effect; readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; + // Re-applies every attached guest's own zoom factor, undoing the zoom level + // Chromium inherits from the embedder when the app UI zooms. + readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; readonly setColorScheme: ( tabId: string, @@ -3876,6 +3920,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { zoomIn: operations.zoomIn, zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, + reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, openDevTools: operations.openDevTools, diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index d03673400ab5..f315bdcec738 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -22,6 +22,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; @@ -102,6 +103,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); +// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. +const MOUSE_BUTTON_BACK = 3; +const MOUSE_BUTTON_FORWARD = 4; + +const navigationDirectionForButton = (button: number): "back" | "forward" | null => { + if (button === MOUSE_BUTTON_BACK) return "back"; + if (button === MOUSE_BUTTON_FORWARD) return "forward"; + return null; +}; + +// Chromium routes thumb-button history navigation to the *focused* WebContents, +// so hovering this guest without focusing it sends the host app's router back +// instead of the preview. Suppress Chromium's default here and drive this tab's +// history explicitly so the buttons always navigate the browser the pointer is +// over — never the host app. +const suppressNavigationButton = (event: MouseEvent): void => { + if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); +}; + +const requestNavigationForButton = (event: MouseEvent): void => { + if (!event.isTrusted) return; + const direction = navigationDirectionForButton(event.button); + if (direction === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); + ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); +}; + +window.addEventListener("mousedown", suppressNavigationButton, true); +window.addEventListener("mouseup", requestNavigationForButton, true); +window.addEventListener("auxclick", suppressNavigationButton, true); + const nextId = (prefix: string): string => { idSequence += 1; return `${prefix}_${idSequence.toString(36)}`; diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index d703dcc6c5ea..daa468281965 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + confirmQuit: true, confirmThreadArchive: true, confirmThreadDelete: false, confirmWorktreeRemoval: true, diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 831f06f02d35..28955debf7b1 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -152,6 +152,108 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("hydrates the locale from the login shell on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + LANG: "de_DE.UTF-8", + }), + }); + + assert.equal(env.LANG, "de_DE.UTF-8"); + }), + ); + + it.effect("preserves an inherited locale over the login shell on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + LANG: "en_US.UTF-8", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + LANG: "de_DE.UTF-8", + }), + }); + + assert.equal(env.LANG, "en_US.UTF-8"); + }), + ); + + it.effect("does not mix login-shell locale categories into an inherited locale", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + LANG: "en_US.UTF-8", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + LC_ALL: "de_DE.UTF-8", + }), + }); + + assert.equal(env.LANG, "en_US.UTF-8"); + assert.equal(env.LC_ALL, undefined); + }), + ); + + it.effect("falls back to a UTF-8 LC_CTYPE when no locale is available on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }), + }); + + assert.equal(env.LANG, undefined); + assert.equal(env.LC_ALL, undefined); + assert.equal(env.LC_CTYPE, "en_US.UTF-8"); + }), + ); + + it.effect("does not apply the locale fallback on linux", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin" }), + }); + + assert.equal(env.LANG, undefined); + }), + ); + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index bd8aa6654f79..e065bf55d046 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -71,6 +71,9 @@ const LOGIN_SHELL_ENV_NAMES = [ "PATH", "DBUS_SESSION_BUS_ADDRESS", "DISPLAY", + "LANG", + "LC_ALL", + "LC_CTYPE", "SSH_AUTH_SOCK", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", @@ -84,6 +87,8 @@ const LOGIN_SHELL_ENV_NAMES = [ "WAYLAND_DISPLAY", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; +const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; +const FALLBACK_LC_CTYPE = "en_US.UTF-8"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); const LAUNCHCTL_TIMEOUT = Duration.seconds(2); @@ -472,6 +477,29 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } + // Locale variables form one precedence group: LC_ALL can override an inherited + // LANG or LC_CTYPE, so only hydrate the group when the process has none of them. + if ( + config.platform === "darwin" && + LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name]))) + ) { + for (const name of LOCALE_ENV_NAMES) { + const value = trimNonEmpty(shellEnvironment[name]); + if (Option.isSome(value)) { + config.env[name] = value.value; + } + } + + // GUI launches inherit no locale from launchd, so spawned agents land in the C + // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported + // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale + // available on those releases. Leaving LANG unset keeps C-stable collation and + // formatting, so output parsing is unaffected. + if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) { + config.env.LC_CTYPE = FALLBACK_LC_CTYPE; + } + } + if ( config.platform === "linux" && Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS)) diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 1cd2ec6327d1..38b0ef7fe2de 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -26,6 +26,7 @@ function makeElectronAppLayer( return Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 1d0bce52864c..b12036db6613 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -122,6 +122,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { runningUnderArm64Translation: false, }), name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index c932c5f845ce..b0f17b880369 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -31,6 +31,7 @@ const environmentInput = { const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index ecdeb6db88bf..4037e8441ffb 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -39,6 +39,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -63,9 +65,14 @@ const environmentInput = { function makeFakeBrowserWindow() { const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); + let zoomLevel = 0; const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "t3code-dev://app/"), + getZoomLevel: vi.fn(() => zoomLevel), + setZoomLevel: vi.fn((level: number) => { + zoomLevel = level; + }), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); @@ -118,12 +125,21 @@ function makeFakeBrowserWindow() { openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, + setZoomLevel: webContents.setZoomLevel, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, windowListeners, }; } +const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ + get: Effect.succeed(Option.none()), +}); + +const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ + quit: Effect.void, +}); + const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { iconPaths: Effect.succeed({ ico: Option.none(), @@ -188,6 +204,7 @@ function makeTestLayer(input: { bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; + readonly previewZoomReapplies?: number[]; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -248,8 +265,10 @@ function makeTestLayer(input: { desktopAssetsLayer, desktopEnvironmentLayer, desktopAppSettingsLayer, + desktopClientSettingsLayer, desktopServerExposureLayer, DesktopState.layer, + electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: (url) => @@ -266,6 +285,10 @@ function makeTestLayer(input: { setMainWindow: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), + reapplyZoom: () => + Effect.sync(() => { + input.previewZoomReapplies?.push(input.window.webContents.getZoomLevel()); + }), }), ), ), @@ -347,7 +370,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n desktopAssetsLayer, desktopEnvironmentLayer, DesktopAppSettings.layerTest(), + desktopClientSettingsLayer, desktopServerExposureLayer, + electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), @@ -485,6 +510,42 @@ describe("DesktopWindow", () => { }), ); + // Chromium hands the main window's zoom level down to embedded preview + // guests, so every app zoom has to put the preview browser back at its own + // zoom or zooming the UI drags the previewed page with it. + it.effect("restores the preview browser's own zoom after zooming the app", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewZoomReapplies: number[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + previewZoomReapplies, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("in"); + yield* desktopWindow.zoomMain("reset"); + + assert.deepEqual( + fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), + [-0.5, -1, -0.5, 0], + ); + // Recorded after the window level moved, so the preview is put back at + // its own zoom on every step rather than left on the inherited one. + assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index fc9baf598c4c..064a620402f1 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -9,6 +9,8 @@ import * as Ref from "effect/Ref"; import * as Electron from "electron"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; @@ -21,9 +23,16 @@ import { import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + QUIT_SHORTCUT_CHANNEL, + WINDOW_FULLSCREEN_STATE_CHANNEL, +} from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import { makeQuitHoldHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -56,6 +65,8 @@ type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets | DesktopAppSettings.DesktopAppSettings + | DesktopClientSettings.DesktopClientSettings + | ElectronApp.ElectronApp | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -280,6 +291,8 @@ export const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const electronApp = yield* ElectronApp.ElectronApp; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -587,7 +600,32 @@ export const make = Effect.gen(function* () { // close-terminal shortcut can outlive the terminal that handled its first // press, so reject repeats before they reach the native window accelerator. // Deliberate presses still flow through the renderer or native menu. + // Chrome-style hold-to-quit: intercept the quit accelerator before the + // native menu sees it and only quit after the shortcut is held. The + // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. + const quitHoldHandler = makeQuitHoldHandler({ + platform: environment.platform, + isEnabled: () => + runPromise( + Effect.map( + clientSettings.get, + Option.match({ + onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, + onSome: (settings) => settings.confirmQuit, + }), + ), + ), + notify: (state) => { + if (!window.isDestroyed()) { + window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); + } + }, + quit: () => { + void runPromise(electronApp.quit); + }, + }); window.webContents.on("before-input-event", (event, input) => { + quitHoldHandler(event, input); if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { @@ -929,6 +967,10 @@ export const make = Effect.gen(function* () { webContents.setZoomLevel( direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); + // Chromium pushes the new level down to embedded guests, which would zoom + // the previewed page along with the app UI. The preview browser keeps its + // own zoom, so put each guest back where the preview left it. + yield* previewManager.reapplyZoom(); }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts new file mode 100644 index 000000000000..c900a865439e --- /dev/null +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + makeQuitHoldHandler, + QUIT_DOUBLE_TAP_MS, + QUIT_HOLD_DURATION_MS, + QUIT_HOLD_RELEASE_GRACE_MS, +} from "./QuitHold.ts"; +import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; + +function makeInput(overrides: Partial): QuitHoldKeyInput { + return { + type: "keyDown", + key: "q", + meta: true, + control: false, + alt: false, + shift: false, + isAutoRepeat: false, + ...overrides, + }; +} + +function makeHarness(options?: { + enabled?: boolean; + platform?: NodeJS.Platform; + isEnabled?: () => Promise; +}) { + const notifications: Array = []; + const quit = vi.fn(); + const handler = makeQuitHoldHandler({ + platform: options?.platform ?? "darwin", + isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), + notify: (state) => notifications.push(state), + quit, + }); + const preventDefault = vi.fn(); + const send = async (input: QuitHoldKeyInput) => { + handler({ preventDefault }, input); + // Let the isEnabled promise settle. + await Promise.resolve(); + await Promise.resolve(); + }; + // Simulates the OS auto-repeating the held shortcut every `intervalMs`. + const holdFor = async ( + durationMs: number, + repeatOverrides: Partial = {}, + intervalMs = 100, + ) => { + for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) { + vi.advanceTimersByTime(intervalMs); + await send(makeInput({ isAutoRepeat: true, ...repeatOverrides })); + } + }; + return { notifications, quit, preventDefault, send, holdFor }; +} + +describe("makeQuitHoldHandler", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows the hint on a tap without quitting, even when the release is never seen", async () => { + // macOS suppresses the letter's keyUp while Cmd is held, so a tap may + // produce no keyUp at all. Quit must still not fire. + const harness = makeHarness(); + await harness.send(makeInput({})); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual(["down"]); + + vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); + expect(harness.quit).not.toHaveBeenCalled(); + // The watchdog dismisses the hint once the press is clearly over. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("quits once the shortcut auto-repeats past the hold duration", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(QUIT_HOLD_DURATION_MS - 200); + expect(harness.quit).not.toHaveBeenCalled(); + await harness.holdFor(400); + expect(harness.quit).toHaveBeenCalledTimes(1); + // Exactly one hint cycle for the whole hold. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("does not quit when the hold stops before the duration", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(500); + await harness.send(makeInput({ type: "keyUp" })); + expect(harness.notifications).toEqual(["down", "up"]); + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("cancels the hold when the modifier is released first", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + expect(harness.notifications).toEqual(["down", "up"]); + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("quits immediately on a single press when disabled", async () => { + const harness = makeHarness({ enabled: false }); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + // The hint is dismissed in case the quit gets cancelled downstream. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("discards a stale isEnabled resolution from a superseded press", async () => { + // Press #1's isEnabled is still pending when the user releases and + // presses again; its late resolution must not act for press #2. + const resolvers: Array<(enabled: boolean) => void> = []; + const harness = makeHarness({ + isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + // Outside the double-tap window, so the second press starts a new hold. + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({})); + expect(resolvers).toHaveLength(2); + + // Press #1 resolves late with "disabled" — it must not quit press #2. + resolvers[0]?.(false); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.quit).not.toHaveBeenCalled(); + + // Press #2 resolves enabled and completes a full hold. + resolvers[1]?.(true); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + + it("quits on a quick double tap, even when the first release was never seen", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + + it("treats two slow taps as separate presses", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual(["down", "up", "down"]); + }); + + it("cancels the hold when another key interrupts it", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(500); + // Shift pressed mid-hold breaks the gesture... + await harness.send(makeInput({ shift: true })); + expect(harness.notifications).toEqual(["down", "up"]); + // ...so later repeats past the threshold must not quit. + await harness.holdFor(QUIT_HOLD_DURATION_MS); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("does not count an interrupted press toward a double tap", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ shift: true })); + // A fresh press right after the interruption starts a new hold, not a + // double-tap quit. + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual(["down", "up", "down"]); + }); + + it("ignores other shortcuts", async () => { + const harness = makeHarness(); + await harness.send(makeInput({ key: "w" })); + await harness.send(makeInput({ shift: true })); + await harness.send(makeInput({ meta: false })); + expect(harness.preventDefault).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it("uses control on non-mac platforms", async () => { + const harness = makeHarness({ platform: "linux" }); + await harness.send(makeInput({ meta: false, control: true })); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts new file mode 100644 index 000000000000..ea2fc7854ac5 --- /dev/null +++ b/apps/desktop/src/window/QuitHold.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. + +// Chrome-style hold-to-quit. The quit accelerator is intercepted in +// before-input-event (which runs before the native menu accelerator), and the +// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS. +// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap +// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application +// menu itself is untouched and quits immediately. +export const QUIT_HOLD_DURATION_MS = 1200; +// A second quick tap of the shortcut is the user insisting: quit immediately. +export const QUIT_DOUBLE_TAP_MS = 500; +// "Still held" is proven by auto-repeat keydowns, not by the absence of a +// release: macOS suppresses a letter's keyUp while the command key is down, so +// a tap's release can go completely unseen and a release-based timer would +// quit anyway. The press is treated as released once no key event has arrived +// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with +// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit. +export const QUIT_HOLD_RELEASE_GRACE_MS = 600; + +export type QuitHoldState = "down" | "up"; + +export interface QuitHoldKeyInput { + readonly type: string; + readonly key: string; + readonly meta: boolean; + readonly control: boolean; + readonly alt: boolean; + readonly shift: boolean; + readonly isAutoRepeat: boolean; +} + +export interface QuitHoldOptions { + readonly platform: NodeJS.Platform; + readonly isEnabled: () => Promise; + readonly notify: (state: QuitHoldState) => void; + readonly quit: () => void; +} + +export function makeQuitHoldHandler( + options: QuitHoldOptions, +): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { + const modifierKey = options.platform === "darwin" ? "meta" : "control"; + let watchdog: NodeJS.Timeout | undefined; + let holding = false; + // Set once isEnabled resolves true; auto-repeats may only quit when armed. + let armed = false; + let heldSince = 0; + let lastPressAt = 0; + // Incremented on every new press and every release/quit so a pending + // isEnabled() resolution from a superseded press cannot arm (or quit for) + // the current one. + let generation = 0; + + const clearWatchdog = () => { + if (watchdog !== undefined) { + clearTimeout(watchdog); + watchdog = undefined; + } + }; + + const release = () => { + if (!holding) return; + generation += 1; + holding = false; + armed = false; + clearWatchdog(); + options.notify("up"); + }; + + // Dismisses the overlay first: if the quit is cancelled downstream the + // renderer must not be left with a stuck "Hold to Quit" hint. + const quitNow = () => { + release(); + options.quit(); + }; + + return (event, input) => { + const key = input.key.toLowerCase(); + if (input.type === "keyUp") { + if (key === "q" || key === modifierKey) release(); + return; + } + if (input.type !== "keyDown") return; + + const modifierDown = options.platform === "darwin" ? input.meta : input.control; + if (!modifierDown || input.alt || input.shift || key !== "q") { + // Any other key (or an extra modifier) pressed mid-hold breaks the + // gesture; without this the hold timer keeps running through the + // interruption and the next qualifying repeat would quit early. The + // interrupted press also stops counting toward a double tap — but only + // here, not in release(), which runs mid-restart on an unseen-release + // re-press and must not wipe that press's own tap timestamp. + if (holding && !input.isAutoRepeat) { + lastPressAt = 0; + release(); + } + return; + } + + event.preventDefault(); + + if (input.isAutoRepeat) { + if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { + quitNow(); + } + return; + } + + const now = Date.now(); + const previousPressAt = lastPressAt; + lastPressAt = now; + // A fresh keydown while "holding" means the key came back down after a + // release macOS never delivered — so both branches below see real taps. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { + quitNow(); + return; + } + if (holding) release(); + + generation += 1; + const pressGeneration = generation; + holding = true; + heldSince = now; + options.notify("down"); + void options.isEnabled().then( + (enabled) => { + if (generation !== pressGeneration) return; + if (!enabled) { + // Hold-to-quit disabled: a single press quits immediately. + quitNow(); + return; + } + armed = true; + // No auto-repeat by then means the key was released (possibly with a + // suppressed keyUp) or repeat is disabled; either way, don't quit. + watchdog = setTimeout(() => { + watchdog = undefined; + release(); + }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); + }, + // A failed settings read must never strand the quit request. + () => { + if (generation !== pressGeneration) return; + quitNow(); + }, + ); + }; +} diff --git a/apps/marketing/src/lib/macArch.test.ts b/apps/marketing/src/lib/macArch.test.ts new file mode 100644 index 000000000000..24a03f2cffe4 --- /dev/null +++ b/apps/marketing/src/lib/macArch.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { macArchFromGpuRenderer } from "./macArch"; + +describe("macArchFromGpuRenderer", () => { + it("detects explicit Apple Silicon renderers", () => { + expect(macArchFromGpuRenderer("Apple M4 Pro")).toBe("arm64"); + }); + + it("prefers an Intel GPU marker even when the renderer also mentions Apple", () => { + expect(macArchFromGpuRenderer("ANGLE Metal Renderer: Apple, Intel Iris Plus Graphics")).toBe( + "x64", + ); + }); + + it("uses x64 for ambiguous Safari and unavailable renderer values", () => { + expect(macArchFromGpuRenderer("Apple GPU")).toBe("x64"); + expect(macArchFromGpuRenderer("")).toBe("x64"); + }); +}); diff --git a/apps/marketing/src/lib/macArch.ts b/apps/marketing/src/lib/macArch.ts new file mode 100644 index 000000000000..8399c4adbdfa --- /dev/null +++ b/apps/marketing/src/lib/macArch.ts @@ -0,0 +1,16 @@ +const INTEL_GPU_PATTERN = /intel|amd|radeon|nvidia|geforce/i; +const APPLE_SILICON_GPU_PATTERN = /\bapple\s+m\d/i; + +export function macArchFromGpuRenderer(renderer: string): "arm64" | "x64" { + if (INTEL_GPU_PATTERN.test(renderer)) { + return "x64"; + } + if (APPLE_SILICON_GPU_PATTERN.test(renderer)) { + return "arm64"; + } + + // Generic "Apple GPU" renderers are ambiguous on Safari. x64 is the safe + // fallback because Apple Silicon can run it through Rosetta, while Intel + // Macs cannot run an arm64 build. + return "x64"; +} diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 20fae288279c..37ff6b69346c 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -391,14 +391,33 @@ const mobileEndorsementRows = [ ', + ), + ]} + />, + ); + + expect(markup).toContain("<script>globalThis.__t3Xss = 1</script>"); + expect(markup).toContain( + "<img src="x" onerror="globalThis.__t3Xss = 2">", + ); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toMatch(/)/i); + }); + + it("continues to render sanitized raw HTML in assistant messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + MoreDetails"), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("More"); + expect(markup).not.toContain("<details>"); + }); + + it("sanitizes executable HTML while preserving supported assistant markup", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + "Safe details", + "", + '', + 'Unsafe link', + "", + ].join(""), + ), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("Safe details"); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toContain("onclick="); + expect(markup).not.toContain("onerror="); + expect(markup).not.toContain("javascript:"); + expect(markup).not.toContain("globalThis.__t3Xss"); + }); + + it("renders inline terminal labels with the composer chip UI", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( ) : null} {trailingWhitespace ? : null} @@ -1834,6 +1837,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} /> ) : null @@ -1922,6 +1926,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />, ); } else if (inlinePrefix.length === 0) { @@ -1947,6 +1952,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} /> ); }); @@ -2179,6 +2185,9 @@ function buildToolCallExpandedBody( return blocks.length > 0 ? blocks.join("\n\n") : null; } +export const toolCallExpandedBodyClassName = + "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[length:var(--font-size-code,0.6875rem)] leading-relaxed select-text"; + function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || @@ -2489,9 +2498,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { onClick={stopRowToggle} onPointerDown={stopRowToggle} > -
-            {expandedBody}
-          
+
{expandedBody}
) : null} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 566f9e425258..31dee6499760 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -10,7 +10,11 @@ import { usageDotRingColor, usageRank, } from "../../aiUsageState"; -import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; +import { + isProviderInstancePickerReady, + shouldShowInstanceBadge, + type ProviderInstanceEntry, +} from "../../providerInstances"; /** * Build the hover tooltip for an instance button. Mirrors the old @@ -93,14 +97,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const [hoveredInstanceId, setHoveredInstanceId] = useState(null); const sidebarContentRef = useRef(null); const [selectedIndicatorTop, setSelectedIndicatorTop] = useState(null); - const duplicateDriverCounts = useMemo(() => { - const counts = new Map(); - for (const entry of props.instanceEntries) { - counts.set(entry.driverKind, (counts.get(entry.driverKind) ?? 0) + 1); - } - return counts; - }, [props.instanceEntries]); - useLayoutEffect(() => { const content = sidebarContentRef.current; if (!content) { @@ -182,8 +178,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const isSelected = props.selectedInstanceId === entry.instanceId; const isHovered = hoveredInstanceId === entry.instanceId; const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; - const showInstanceBadge = - Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; + const showInstanceBadge = shouldShowInstanceBadge(entry, props.instanceEntries); const tooltip = isUnavailable ? describeUnavailableInstance(entry) diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index e53c3f8b1b6d..53075d3722cf 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -19,7 +19,7 @@ import { getTriggerDisplayModelLabel, getTriggerDisplayModelName, } from "./providerIconUtils"; -import type { ProviderInstanceEntry } from "../../providerInstances"; +import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, ComposerControlChevron } from "./ComposerControl"; import { useClientSettings } from "~/hooks/useSettings"; @@ -83,10 +83,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { selectedInstanceOptions[0]; const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; - const duplicateDriverCount = orderedInstanceEntries.filter( - (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind, - ).length; - const showInstanceBadge = Boolean(activeEntry?.accentColor) || duplicateDriverCount > 1; + const showInstanceBadge = + activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); const activeUsage = useMemo( () => resolveDriverUsage(props.usageSnapshot, activeEntry?.driverKind ?? null, props.model), [props.usageSnapshot, activeEntry, props.model], diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 303b028fd28f..fc37b2ff30df 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -328,16 +328,23 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ closeOnClick disabled={ultrathinkInBodyText && descriptor.id === primarySelectDescriptor?.id} > - - - {option.label} - {option.isDefault ? ( - <> - {" "} - - - ) : null} + + + + {option.label} + {option.isDefault ? ( + <> + {" "} + + + ) : null} + + {option.description ? ( + + {option.description} + + ) : null} ))} diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts new file mode 100644 index 000000000000..239db28a6002 --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -0,0 +1,170 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { submitComposerDraft } from "./composerSubmission"; + +describe("submitComposerDraft", () => { + it("keeps an oversized draft editable and sends a corrected follow-up", () => { + let draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + let validationMessage: string | null = null; + const dispatchedDrafts: string[] = []; + const preventDefault = vi.fn(); + + const submit = () => { + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => { + dispatchedDrafts.push(draft); + }, + }); + validationMessage = result.validationMessage; + }; + + submit(); + + expect(dispatchedDrafts).toEqual([]); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + expect(validationMessage).toBe( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(preventDefault).toHaveBeenCalledOnce(); + + draft = "Corrected prompt"; + submit(); + + expect(dispatchedDrafts).toEqual(["Corrected prompt"]); + expect(validationMessage).toBeNull(); + }); + + it("allows a draft at the shared character limit through the normal send path", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("blocks when appended context pushes the provider input over the shared limit", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + providerInput: `${draft}\n\nTerminal context`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ + validationMessage: + "Prompt is 18 characters over the 120,000-character limit. Shorten or split it before sending.", + didDispatch: false, + }); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + expect(onSend).not.toHaveBeenCalled(); + + const correctedResult = submitComposerDraft({ + prompt: "Corrected prompt", + providerInput: "Corrected prompt\n\nShort terminal context", + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(correctedResult).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("does not finish submission when the send boundary rejects composed provider input", () => { + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Sendable raw draft", + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => false, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: false }); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("allows fully composed provider input at the shared character limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Short draft", + providerInput: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("blocks a generated plan follow-up that exceeds the shared limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "", + providerInput: `PLEASE IMPLEMENT THIS PLAN:\n${"x".repeat( + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + )}`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result.didDispatch).toBe(false); + expect(result.validationMessage).toContain("over the 120,000-character limit"); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("allows surrounding whitespace that the provider turn contract trims", () => { + const draft = ` ${"x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)} `; + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("dispatches pending user input answers on their separate response path", () => { + const answer = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: answer, + submissionTarget: "pending-user-input", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts new file mode 100644 index 000000000000..528ac75bcabe --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -0,0 +1,44 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; + +type ComposerSubmitEvent = { preventDefault: () => void }; + +type ComposerSubmissionInput = { + prompt: string; + providerInput?: string; + submissionTarget: "provider-turn" | "pending-user-input"; +}; + +export function getComposerPromptLengthValidationMessage(prompt: string): string | null { + const excessCharacters = prompt.trim().length - PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + if (excessCharacters <= 0) return null; + + const characterLabel = excessCharacters === 1 ? "character" : "characters"; + return `Prompt is ${excessCharacters.toLocaleString("en-US")} ${characterLabel} over the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS.toLocaleString("en-US")}-character limit. Shorten or split it before sending.`; +} + +export function getComposerSubmissionValidationMessage( + options: ComposerSubmissionInput, +): string | null { + return options.submissionTarget === "provider-turn" + ? getComposerPromptLengthValidationMessage(options.providerInput ?? options.prompt) + : null; +} + +export function submitComposerDraft( + options: ComposerSubmissionInput & { + event: ComposerSubmitEvent | undefined; + onSend: (event?: ComposerSubmitEvent) => boolean | void; + }, +): { validationMessage: string | null; didDispatch: boolean } { + const validationMessage = getComposerSubmissionValidationMessage(options); + if (validationMessage) { + options.event?.preventDefault(); + return { validationMessage, didDispatch: false }; + } + + if (options.onSend(options.event) === false) { + options.event?.preventDefault(); + return { validationMessage: null, didDispatch: false }; + } + return { validationMessage: null, didDispatch: true }; +} diff --git a/apps/web/src/components/chat/workspaceFileDrop.test.ts b/apps/web/src/components/chat/workspaceFileDrop.test.ts new file mode 100644 index 000000000000..ec5d074a3eb7 --- /dev/null +++ b/apps/web/src/components/chat/workspaceFileDrop.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { + makeWorkspaceFileDropHandlers, + type WorkspaceFileDragEvent, + type WorkspaceFileDropHost, +} from "./workspaceFileDrop"; + +function makeDragEvent(options?: { + types?: string[]; + files?: File[]; + movedWithinTarget?: boolean; +}) { + const preventDefault = vi.fn(); + const event = { + dataTransfer: { + types: options?.types ?? ["Files"], + files: options?.files ?? [], + dropEffect: "none", + }, + relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, + currentTarget: { + contains: () => options?.movedWithinTarget ?? false, + }, + preventDefault, + } satisfies WorkspaceFileDragEvent; + return { event, preventDefault }; +} + +function makeHost() { + const setDragActive = vi.fn(); + const addFiles = vi.fn(); + const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost; + return { host, setDragActive, addFiles }; +} + +describe("makeWorkspaceFileDropHandlers", () => { + it("activates the target for an external file drag", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent(); + + makeWorkspaceFileDropHandlers(host).onDragEnter(event); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(setDragActive).toHaveBeenCalledWith(true); + }); + + it("ignores non-file drags", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent({ types: ["text/plain"] }); + + makeWorkspaceFileDropHandlers(host).onDragOver(event); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("does not flicker when the drag moves between children", () => { + const { host, setDragActive } = makeHost(); + const { event } = makeDragEvent({ movedWithinTarget: true }); + + const handlers = makeWorkspaceFileDropHandlers(host); + handlers.onDragEnter(event); + handlers.onDragLeave(event); + + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("forwards dropped files and clears the active state", () => { + const file = new File(["contents"], "example.txt", { type: "text/plain" }); + const { host, setDragActive, addFiles } = makeHost(); + const { event } = makeDragEvent({ files: [file] }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(setDragActive).toHaveBeenCalledWith(false); + expect(addFiles).toHaveBeenCalledWith([file]); + }); +}); diff --git a/apps/web/src/components/chat/workspaceFileDrop.ts b/apps/web/src/components/chat/workspaceFileDrop.ts new file mode 100644 index 000000000000..132a8051e159 --- /dev/null +++ b/apps/web/src/components/chat/workspaceFileDrop.ts @@ -0,0 +1,54 @@ +export interface WorkspaceFileDragEvent { + readonly dataTransfer: { + readonly types: ReadonlyArray; + readonly files: Iterable; + dropEffect: string; + }; + readonly relatedTarget: EventTarget | null; + readonly currentTarget: { + contains(target: Node | null): boolean; + }; + preventDefault(): void; +} + +export interface WorkspaceFileDropHost { + setDragActive(active: boolean): void; + addFiles(files: File[]): void; +} + +function isFileDrag(event: WorkspaceFileDragEvent): boolean { + return event.dataTransfer.types.includes("Files"); +} + +function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { + return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); +} + +export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { + return { + onDragEnter(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(true); + }, + onDragOver(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + host.setDragActive(true); + }, + onDragLeave(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(false); + }, + onDrop(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + host.setDragActive(false); + host.addFiles(Array.from(event.dataTransfer.files)); + }, + }; +} diff --git a/apps/web/src/components/clerk/authRedirect.test.ts b/apps/web/src/components/clerk/authRedirect.test.ts index 140474120cca..e948d1d9c049 100644 --- a/apps/web/src/components/clerk/authRedirect.test.ts +++ b/apps/web/src/components/clerk/authRedirect.test.ts @@ -5,7 +5,10 @@ import { resolveClerkSignInProps } from "./authRedirect"; describe("resolveClerkSignInProps", () => { it("returns to the current browser URL on the web", () => { const href = "https://app.t3.codes/connect?state=state-1#details"; - expect(resolveClerkSignInProps(href, false)).toEqual({ forceRedirectUrl: href }); + expect(resolveClerkSignInProps(href, false)).toEqual({ + forceRedirectUrl: href, + signUpForceRedirectUrl: href, + }); }); it("removes a Clerk virtual pathname and callback params while preserving the desktop route", () => { diff --git a/apps/web/src/components/clerk/authRedirect.ts b/apps/web/src/components/clerk/authRedirect.ts index 251c5ee36502..e0b07241c068 100644 --- a/apps/web/src/components/clerk/authRedirect.ts +++ b/apps/web/src/components/clerk/authRedirect.ts @@ -15,5 +15,7 @@ export function resolveClerkSignInProps(href: string, isElectron: boolean): Cler signUpForceRedirectUrl: redirectUrl.toString(), }; } - return { forceRedirectUrl: href }; + // The sign-in modal can switch to sign-up, which follows its own redirect + // target; without one Clerk falls back to the URL the modal was opened from. + return { forceRedirectUrl: href, signUpForceRedirectUrl: href }; } diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index e47d8ddf7f7c..5d5c280bb81c 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -1,9 +1,10 @@ import { useAuth, useClerk, useUser } from "@clerk/react"; import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, readConnectCliAuthState, readConnectCliCallbackResult, rememberConnectCliAuthState, @@ -56,6 +57,21 @@ export function ConnectCliAuthorizeSurface() { const signInOpened = useRef(false); const redirecting = useRef(false); + const openSignIn = useCallback(() => { + if (!request) { + return; + } + // Clerk redirects to the authorize endpoint itself once sign-in completes, + // so the callback's state check has to be armed before handing off. + rememberConnectCliAuthState(request.state); + clerk.openSignIn( + resolveClerkSignInProps( + connectCliSignInRedirectUrl(request, window.location.href), + isElectron, + ), + ); + }, [clerk, request]); + useEffect(() => { if (!request || !isLoaded || redirecting.current) { return; @@ -63,7 +79,7 @@ export function ConnectCliAuthorizeSurface() { if (!isSignedIn) { if (!signInOpened.current) { signInOpened.current = true; - clerk.openSignIn(resolveClerkSignInProps(window.location.href, isElectron)); + openSignIn(); } return; } @@ -74,7 +90,7 @@ export function ConnectCliAuthorizeSurface() { redirecting.current = true; rememberConnectCliAuthState(request.state); window.location.assign(authorizeUrl); - }, [clerk, isLoaded, isSignedIn, request]); + }, [isLoaded, isSignedIn, openSignIn, request]); if (!request) { return ( @@ -101,12 +117,7 @@ export function ConnectCliAuthorizeSurface() { /> {isLoaded && !isSignedIn ? (
-
diff --git a/apps/web/src/components/preview/PreviewPanelShell.test.ts b/apps/web/src/components/preview/PreviewPanelShell.test.ts index 31258b166bdf..23deb066a2ab 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.test.ts +++ b/apps/web/src/components/preview/PreviewPanelShell.test.ts @@ -20,4 +20,30 @@ describe("getPreviewPanelMaxWidth", () => { expect(markup).toContain("max-w-full"); }); + + it("reserves the sibling column minimum when the flex row is known", () => { + // Fullscreen 14" MacBook: viewport 1512, sidebar ~256 → row of 1256. + // The 70% fraction (1058) would leave the chat column only ~198px; + // the container clamp caps the panel at 1256 − 360 instead. + expect(getPreviewPanelMaxWidth(1_512, 1_256)).toBe(896); + }); + + it("keeps the fraction cap when the row is wide enough for both columns", () => { + expect(getPreviewPanelMaxWidth(3_000, 2_900)).toBe(2_100); + }); + + it("rounds fractional row widths down", () => { + expect(getPreviewPanelMaxWidth(1_512, 1_256.6)).toBe(896); + }); + + it("never drops below the panel minimum when the row cannot fit both columns", () => { + // ~1000px window with an expanded sidebar → row of 700. The sibling + // reservation (700 − 360 = 340) would undercut the panel's own 360 + // minimum and invert the resize clamp, so the floor wins. + expect(getPreviewPanelMaxWidth(1_000, 700)).toBe(360); + }); + + it("stays at the panel minimum even when the row is narrower than the reservation", () => { + expect(getPreviewPanelMaxWidth(1_512, 300)).toBe(360); + }); }); diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 17ca389feab2..7a20c2eaaa03 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -1,4 +1,11 @@ -import { type ReactNode, useEffect, useState } from "react"; +import { + type ReactNode, + type RefObject, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { isElectron } from "~/env"; import { useResizableWidth } from "~/hooks/useResizableWidth"; @@ -10,12 +17,31 @@ export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; const PREVIEW_PANEL_MIN_WIDTH = 360; -/** Fraction of the viewport allowed, preserving the remaining space for chat. */ +/** + * Upper bound as a fraction of the viewport; only binds on wide screens. + * On narrow windows the container clamp below is what preserves the + * sibling column's space. + */ const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; const PREVIEW_PANEL_DEFAULT_WIDTH = 540; +/** + * Width reserved for the sibling column (chat, pull-request list) sharing the + * panel's flex row. The viewport fraction alone is not enough: the app + * sidebar sits outside the row, so on narrow windows (any MacBook, even + * fullscreen) the remaining 30% of the viewport minus the sidebar left the + * sibling below its usable width and the composer overflowed. + */ +const SIBLING_COLUMN_MIN_WIDTH = 360; -export function getPreviewPanelMaxWidth(viewportWidth: number): number { - return Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); +export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { + const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); + const containerCap = + containerWidth === undefined ? Infinity : Math.floor(containerWidth) - SIBLING_COLUMN_MIN_WIDTH; + // Never below the panel's own minimum: when the row cannot fit both + // columns' minimums the sibling yields, and useResizableWidth's clamp + // must not see max < min (it would resolve the inversion to min and, + // via drag-end persistence, overwrite the user's stored width). + return Math.max(PREVIEW_PANEL_MIN_WIDTH, Math.min(fractionCap, containerCap)); } /** @@ -39,7 +65,10 @@ export function PreviewPanelShell(props: { }) { const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; const isInline = props.mode === "inline"; - const maxWidth = useViewportClampedMaxWidth(); + const hostRef = useRef(null); + // Only inline non-maximized mode applies `width`/`maxWidth`; skip the + // container measurement (and its re-renders) everywhere else. + const maxWidth = useClampedMaxWidth(hostRef, isInline && !props.maximized); const { width, handlers } = useResizableWidth({ storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, @@ -50,6 +79,7 @@ export function PreviewPanelShell(props: { return (
, enabled: boolean): number { const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); + const [containerWidth, setContainerWidth] = useState(undefined); useEffect(() => { if (typeof window === "undefined") return; let frame = 0; @@ -93,5 +128,24 @@ function useViewportClampedMaxWidth(): number { if (frame !== 0) window.cancelAnimationFrame(frame); }; }, []); - return getPreviewPanelMaxWidth(vw); + useLayoutEffect(() => { + if (!enabled) return; + const parent = hostRef.current?.parentElement; + if (!parent) return; + // Measure before first paint: the persisted width must be clamped + // against the row on the initial render, not one observer tick later + // (the panel would flash over-wide on every mount). clientWidth is + // integral, so sub-pixel resize deltas bail out of re-rendering. + const measure = () => { + setContainerWidth(parent.clientWidth); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(parent); + return () => { + observer.disconnect(); + }; + }, [hostRef, enabled]); + return getPreviewPanelMaxWidth(vw, containerWidth); } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 3e7c46ef0e0a..2bdba1afe9e3 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; -import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react"; +import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -17,6 +17,7 @@ import { clampPreviewMiniPlayerPosition, clampPreviewMiniPlayerSize, PREVIEW_MINI_PLAYER_DEFAULT_SIZE, + PREVIEW_MINI_PLAYER_EDGE_GAP, } from "./previewMiniPlayerLayout"; interface DragState { @@ -31,6 +32,8 @@ interface ResizeState { readonly pointerId: number; readonly pointerX: number; readonly pointerY: number; + readonly playerX: number; + readonly playerY: number; readonly width: number; readonly height: number; } @@ -45,6 +48,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const rootRef = useRef(null); const dragRef = useRef(null); const resizeRef = useRef(null); + const [defaultLayoutVersion, setDefaultLayoutVersion] = useState(""); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); @@ -91,8 +95,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props bottomInset, ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); + if (!position) { + setDefaultLayoutVersion(`${parent.clientWidth}:${parent.clientHeight}`); + return; + } const next = clampPreviewMiniPlayerPosition( - position ?? { x: root.offsetLeft, y: root.offsetTop }, + position, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, @@ -159,11 +167,16 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const handleResizePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; const root = rootRef.current; - if (!root) return; + const parent = root?.offsetParent; + if (!root || !(parent instanceof HTMLElement)) return; + const rootRect = root.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); resizeRef.current = { pointerId: event.pointerId, pointerX: event.clientX, pointerY: event.clientY, + playerX: rootRect.left - parentRect.left, + playerY: rootRect.top - parentRect.top, width: root.offsetWidth, height: root.offsetHeight, }; @@ -194,7 +207,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); const nextPosition = clampPreviewMiniPlayerPosition( - position ?? { x: root.offsetLeft, y: root.offsetTop }, + { x: resize.playerX, y: resize.playerY }, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, @@ -222,8 +235,8 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props position ? { left: position.x, top: position.y, width: size.width, height: size.height } : { - right: 16, - top: 16, + right: PREVIEW_MINI_PLAYER_EDGE_GAP, + top: PREVIEW_MINI_PLAYER_EDGE_GAP, width: size.width, height: size.height, } @@ -290,7 +303,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props visible={Boolean(desktopOverlay?.hasWebContents)} cornerRadius={12} fitSourceContent - layoutVersion={position ? `${position.x}:${position.y}` : `initial:${bottomInset}`} + layoutVersion={ + position + ? `${position.x}:${position.y}` + : `initial:${bottomInset}:${defaultLayoutVersion}` + } className="absolute inset-0" />
diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 71f3ffc7f378..776a4d671368 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -6,6 +6,7 @@ import type { PullRequestDiffSide, PullRequestOmittedFileStat, PullRequestRef, + PullRequestReviewPosition, PullRequestReviewThread, } from "@t3tools/contracts"; import { @@ -43,7 +44,11 @@ import { } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; -import { buildDiffReviewComment, type ReviewCommentContext } from "~/reviewCommentContext"; +import { + buildDiffReviewComment, + resolveDiffReviewPosition, + type ReviewCommentContext, +} from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -129,8 +134,7 @@ interface DraftAnchor { readonly path: string; /** What the file was called before the change, for the hosts that resolve a position by both. */ readonly oldPath: string | null; - readonly line: number; - readonly side: PullRequestDiffSide; + readonly position: PullRequestReviewPosition; /** The whole selection, which the comment collapses to one line but a question keeps. */ readonly range: SelectedLineRange; } @@ -148,8 +152,21 @@ function toViewerSide(side: PullRequestDiffSide) { return side === "left" ? ("deletions" as const) : ("additions" as const); } -function fromViewerSide(side: string | undefined): PullRequestDiffSide { - return side === "deletions" ? "left" : "right"; +function getReviewPositionAnchor(position: PullRequestReviewPosition): { + line: number; + side: PullRequestDiffSide; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "right" }; + case "deleted": + return { line: position.oldLine, side: "left" }; + case "context": + return { + line: position.side === "left" ? position.oldLine : position.newLine, + side: position.side, + }; + } } /** @@ -442,10 +459,14 @@ export function PullRequestCodeTab({ if (commit === null) { for (const comment of pendingComments) { if (comment.path !== path) continue; - groupAt(comment.side, comment.line).pending.push(comment); + const anchor = getReviewPositionAnchor(comment.position); + groupAt(anchor.side, anchor.line).pending.push(comment); } } - if (draft?.fileKey === fileKey) groupAt(draft.side, draft.line).draft = true; + if (draft?.fileKey === fileKey) { + const anchor = getReviewPositionAnchor(draft.position); + groupAt(anchor.side, anchor.line).draft = true; + } const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); @@ -594,12 +615,13 @@ export function PullRequestCodeTab({ // that silently lost its first line on the other hosts would be worse than one line. const path = resolveFileDiffPath(file); const previousPath = resolveFileDiffPreviousPath(file); + const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); + if (position === null) return; setDraft({ fileKey: item.id, path, oldPath: previousPath === path ? null : previousPath, - line: range.end, - side: fromViewerSide(range.endSide ?? range.side), + position, range, }); }, @@ -842,7 +864,7 @@ export function PullRequestCodeTab({ {annotation.metadata.draft && draft ? ( { diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts index 8e207c2529b8..41906a710fc8 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts @@ -6,17 +6,10 @@ * hosts that have no pending review of their own. That also means a draft lives only as long * as the tab does, which is why this is deliberately not persisted. */ -import type { ProjectId, PullRequestDiffSide, PullRequestRef } from "@t3tools/contracts"; +import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; import { create } from "zustand"; -export interface PendingReviewComment { - readonly id: string; - readonly path: string; - /** The line in the file the comment's side names: the new file on the right, the old on the left. */ - readonly line: number; - readonly side: PullRequestDiffSide; - readonly body: string; -} +export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string }; /** * A counter rather than anything derived from the comment: two remarks on one line can be the diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 8e91e71519da..83be441ba938 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1660,23 +1660,25 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b return ( <> - void updateManagedTunnel(enabled)} - /> - } - /> + {window.desktopBridge ? ( + void updateManagedTunnel(enabled)} + /> + } + /> + ) : null} { expect(options).not.toContain("customModeActive"); }); - it("builds command options from defaults and resolved project bindings", () => { + it("builds command options from built-in commands and resolved project bindings", () => { const options = buildKeybindingCommandOptions([ { command: "script.setup-db.run", @@ -151,7 +151,9 @@ describe("KeybindingsSettings.logic", () => { }, ] satisfies ResolvedKeybindingsConfig); - expect(options).toEqual(expect.arrayContaining(["chat.new", "script.setup-db.run"])); + expect(options).toEqual( + expect.arrayContaining(["chat.new", "rightPanel.toggleMaximized", "script.setup-db.run"]), + ); }); it("reports unknown when variables without rejecting parseable expressions", () => { diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 2514c851cf46..ee114682212a 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -1,4 +1,5 @@ import { + STATIC_KEYBINDING_COMMANDS, type KeybindingCommand, type KeybindingShortcut, type KeybindingWhenNode, @@ -255,10 +256,7 @@ export function buildWhenVariableOptions(): ReadonlyArray { export function buildKeybindingCommandOptions( keybindings: ResolvedKeybindingsConfig, ): ReadonlyArray { - const commands = new Set(); - for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) { - commands.add(binding.command); - } + const commands = new Set(STATIC_KEYBINDING_COMMANDS); for (const binding of keybindings) { commands.add(binding.command); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 3870b8e102f3..58c808dcc731 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -534,11 +534,15 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmWorktreeRemoval !== DEFAULT_UNIFIED_SETTINGS.confirmWorktreeRemoval ? ["Worktree remove confirmation"] : []), + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit + ? ["Quit confirmation"] + : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ], [ isTextGenerationModelDirty, isBackgroundActivityDirty, + settings.confirmQuit, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.confirmWorktreeRemoval, @@ -656,6 +660,7 @@ export function useSettingsRestore(onRestored?: () => void) { confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, confirmWorktreeRemoval: DEFAULT_UNIFIED_SETTINGS.confirmWorktreeRemoval, + confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, @@ -2273,6 +2278,30 @@ export function GeneralSettingsPanel() { } /> + {isElectron ? ( + + updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) + } + /> + ) : null + } + control={ + updateSettings({ confirmQuit: Boolean(checked) })} + aria-label="Hold to quit" + /> + } + /> + ) : null} + { expect(searchSettings(" ", ITEMS)).toEqual([]); }); + it("hides desktop-only settings from browser search", () => { + expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); + expect(searchSettings("quit confirmation")).toEqual([]); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index fb0bd25dea33..9f0d5f874210 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,3 +1,5 @@ +import { isElectron } from "~/env"; + export type SettingsPath = | "/settings/general" | "/settings/appearance" @@ -12,6 +14,9 @@ export interface SettingsSearchItem { readonly title: string; readonly to: SettingsPath; readonly targetId?: string; + // Its row only renders in the desktop app, so a browser result would land on + // an anchor that isn't there. + readonly desktopOnly?: boolean; } /** @@ -154,6 +159,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Worktree remove confirmation", to: "/settings/general", }, + { + id: "quit-confirmation", + title: "Hold to quit", + to: "/settings/general", + desktopOnly: true, + }, { id: "text-generation-model", title: "Text generation model", @@ -241,5 +252,9 @@ export function searchSettings( const normalizedQuery = normalizeSearchText(query); if (normalizedQuery.length === 0) return []; - return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery)); + return items.filter( + (item) => + (isElectron || item.desktopOnly !== true) && + normalizeSearchText(item.title).includes(normalizedQuery), + ); } diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 93dc653e7c0a..c839ddc3be75 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -9,6 +9,7 @@ const baseState: ThreadActionMenuState = { isSnoozed: false, canSnoozeNow: true, isRegeneratingTitle: false, + isRunning: false, supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, @@ -26,7 +27,7 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]); }); it("includes branch items only for threads with a branch", () => { @@ -63,4 +64,28 @@ describe("buildThreadActionMenuItems", () => { const items = buildThreadActionMenuItems({ ...baseState, branch: "main" }); expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true }); }); + + it("offers archive as a non-destructive action right before delete", () => { + const items = buildThreadActionMenuItems(baseState); + const archiveItem = items.at(-2); + expect(archiveItem?.id).toBe("archive"); + expect(archiveItem?.destructive).toBeFalsy(); + expect(items.at(-1)?.id).toBe("delete"); + }); + + it("keeps archive available even when the environment lacks every other capability", () => { + expect( + ids({ + ...baseState, + supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + }), + ).toContain("archive"); + }); + + it("disables archive while the thread is running", () => { + const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( + (item) => item.id === "archive", + ); + expect(archiveItem?.disabled).toBe(true); + }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 2d32954c0193..ddbe0769563f 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -21,6 +21,7 @@ export type ThreadActionMenuId = | "copy-path" | "copy-branch" | "copy-thread-id" + | "archive" | "delete"; export interface ThreadActionMenuState { @@ -30,6 +31,8 @@ export interface ThreadActionMenuState { readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; + /** Archive rejects a thread with an active turn, so disable it here rather than let the action fail. */ + readonly isRunning: boolean; readonly supports: { readonly settlement: boolean; readonly snooze: boolean; @@ -105,6 +108,12 @@ export function buildThreadActionMenuItems( // for the same reason — thread ids are the currency of Discord bridges // and deep links. One entry, in upstream's position. { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, + // Archive removes the thread from the sidebar while keeping its + // conversation under Settings > Archived threads — distinct from Settle + // (stays visible in the Settled shelf) and Delete (clears history for + // good), so it sits beside Delete without borrowing its destructive + // styling. + { id: "archive", label: "Archive thread", disabled: state.isRunning }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/components/ui/toast.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts index e3f6763865e0..5d830005cfe1 100644 --- a/apps/web/src/components/ui/toast.logic.test.ts +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -2,10 +2,23 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { assert, describe, it } from "vite-plus/test"; import { buildVisibleToastLayout, + hasVisibleToastAction, shouldHideCollapsedToastContent, shouldRenderThreadScopedToast, } from "./toast.logic"; +describe("hasVisibleToastAction", () => { + it("treats a labeled action as visible", () => { + assert.equal(hasVisibleToastAction({ children: "Update" }), true); + }); + + it("hides an explicit empty action used to clear a previous CTA", () => { + assert.equal(hasVisibleToastAction({ children: null }), false); + assert.equal(hasVisibleToastAction({ children: "" }), false); + assert.equal(hasVisibleToastAction(undefined), false); + }); +}); + describe("shouldHideCollapsedToastContent", () => { it("keeps a single visible toast readable", () => { assert.equal(shouldHideCollapsedToastContent(0, 1), false); diff --git a/apps/web/src/components/ui/toast.logic.ts b/apps/web/src/components/ui/toast.logic.ts index 8ab55feee0bb..f6dd2b1e2cf6 100644 --- a/apps/web/src/components/ui/toast.logic.ts +++ b/apps/web/src/components/ui/toast.logic.ts @@ -1,5 +1,21 @@ import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +/** + * Base UI toast updates omit `undefined` fields, so callers that need to remove + * an action must pass a defined `actionProps` whose `children` are empty. + * Treat that payload (and missing children) as "no visible action". + */ +export function hasVisibleToastAction(actionProps: unknown): boolean { + if (actionProps == null || typeof actionProps !== "object") { + return false; + } + if (!("children" in actionProps)) { + return false; + } + const children = actionProps.children; + return children != null && children !== false && children !== ""; +} + export function shouldHideCollapsedToastContent( visibleToastIndex: number, visibleToastCount: number, diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 8980a9243c0c..7eaed8d25ecf 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -39,6 +39,7 @@ import { } from "./errorDetailText"; import { buildVisibleToastLayout, + hasVisibleToastAction, shouldHideCollapsedToastContent, shouldRenderThreadScopedToast, } from "./toast.logic"; @@ -343,7 +344,7 @@ function deriveToastBodyDescriptor(toast: { }): ToastBodyDescriptor { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const stackedActionLayout = - toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; + hasVisibleToastAction(toast.actionProps) && toast.data?.actionLayout === "stacked-end"; const actionVariant: NonNullable = toast.data?.actionVariant ?? "default"; const secondaryActionVariant: NonNullable = @@ -356,7 +357,7 @@ function deriveToastBodyDescriptor(toast: { const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; const hasTrailingControls = copyErrorText !== null || - toast.actionProps !== undefined || + hasVisibleToastAction(toast.actionProps) || hasAdditionalActions || hasSecondaryAction; const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; @@ -486,12 +487,12 @@ function ToastBodyContent({ variant={secondaryActionVariant} /> ) : null} - {actionProps ? ( + {hasVisibleToastAction(actionProps) ? ( - {actionProps.children} + {actionProps?.children} ) : null}
@@ -896,7 +897,7 @@ function AnchoredToasts() { ); } -export { stackedThreadToast } from "./toastHelpers"; +export { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers"; export type { StackedThreadToastOptions } from "./toastHelpers"; export { diff --git a/apps/web/src/components/ui/toastHelpers.test.ts b/apps/web/src/components/ui/toastHelpers.test.ts new file mode 100644 index 000000000000..c356aef4030a --- /dev/null +++ b/apps/web/src/components/ui/toastHelpers.test.ts @@ -0,0 +1,21 @@ +import { assert, describe, it } from "vite-plus/test"; + +import { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers"; + +describe("hiddenToastActionProps", () => { + it("is a defined update payload so Base UI can replace a previous action", () => { + assert.equal(hiddenToastActionProps.children, null); + assert.equal( + "actionProps" in stackedThreadToast({ type: "loading", title: "Updating" }), + false, + ); + assert.deepEqual( + stackedThreadToast({ + type: "loading", + title: "Updating", + actionProps: hiddenToastActionProps, + }).actionProps, + hiddenToastActionProps, + ); + }); +}); diff --git a/apps/web/src/components/ui/toastHelpers.ts b/apps/web/src/components/ui/toastHelpers.ts index 699e97a7e39d..f341034c6738 100644 --- a/apps/web/src/components/ui/toastHelpers.ts +++ b/apps/web/src/components/ui/toastHelpers.ts @@ -17,6 +17,14 @@ export type StackedThreadToastOptions = { data?: Omit; }; +/** + * Defined `actionProps` that hide a previous toast CTA on `toastManager.update`. + * Passing `actionProps: undefined` is a no-op because updates omit undefined keys. + */ +export const hiddenToastActionProps = { + children: null, +} as const satisfies Pick, "children">; + /** * Thread toast using the stacked body + bottom action row (copy for errors, CTA on its own row). */ diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 105bb0d0cd7e..3d8bfaf5ba98 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -25,7 +25,7 @@ import { SidebarInset } from "../ui/sidebar"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -209,7 +209,7 @@ export function UsagePage() {
- {PROVIDER_LABEL[provider.provider]} + {PROVIDER_PRESENTATION[provider.provider].label} {metric === "cost" @@ -222,7 +222,7 @@ export function UsagePage() { className="h-full" style={{ width: `${(share * 100).toFixed(1)}%`, - backgroundColor: PROVIDER_COLOR[provider.provider], + backgroundColor: PROVIDER_PRESENTATION[provider.provider].color, }} />
@@ -385,7 +385,7 @@ export function UsagePage() { {isPast24Hours ? "Hour" : "Day"} {PROVIDER_ORDER.map((provider) => ( - {PROVIDER_LABEL[provider]} + {PROVIDER_PRESENTATION[provider].label} ))} Total @@ -454,7 +454,7 @@ function ProviderMark({ readonly provider: UsageProviderKind; readonly className: string; }) { - const Mark = PROVIDER_MARK[provider]; + const Mark = PROVIDER_PRESENTATION[provider].mark; return ; } @@ -601,7 +601,7 @@ function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" })
- {PROVIDER_LABEL[provider]} + {PROVIDER_PRESENTATION[provider].label}
diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index f41945bfe286..d7582a0e4bdc 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -9,7 +9,7 @@ import { formatTokens, formatUsd, } from "@t3tools/shared/usageFormat"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; const VIEW_WIDTH = 960; const VIEW_HEIGHT = 260; @@ -339,14 +339,19 @@ export function UsageProviderChart({ {/* Fills first, then every stroke, so no series covers another's line. */} {paths.map(({ provider, area }) => ( - + ))} {paths.map(({ provider, line }) => ( @@ -376,12 +381,12 @@ export function UsageProviderChart({ >
{formatTooltipPeriod(hoveredPeriod)}
{PROVIDER_ORDER.map((provider) => { - const Mark = PROVIDER_MARK[provider]; + const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; return (
- {PROVIDER_LABEL[provider]} + {label} {format( @@ -423,13 +428,13 @@ export function UsageChartLegend() { return (
{PROVIDER_ORDER.map((provider) => { - // The marks carry the same fills as the bands, so they key the chart - // just as a colour swatch would. - const Mark = PROVIDER_MARK[provider]; + // Brand marks keep monochrome providers identifiable even when their + // chart series use distinct colors. + const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; return ( - {PROVIDER_LABEL[provider]} + {label} ); })} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 05f11010bb4a..d469f2f7ce87 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -2,60 +2,42 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, GrokIcon, type Icon, KimiIcon, OpenAI } from "../Icons"; -/** - * Series and table order. The chart layers the providers from a shared zero - * baseline, so this only fixes the reading order of legends, tables and hover - * rows; it does not decide which series sits above the others. - */ -export const PROVIDER_ORDER = [ - "codex", - "claude", - "grok", - "kimi", -] as const satisfies readonly UsageProviderKind[]; - -/** - * A provider added to `UsageProviderKind` but not to {@link PROVIDER_ORDER} - * would still appear in the summary rows (those come from `merged.providers`) - * while silently vanishing from the daily columns, chart bands, legends and - * skeletons, all of which iterate this order. The `Record` maps below are - * exhaustive by their own type; this makes the order exhaustive too, so the - * omission is a compile error rather than a missing column nobody notices. - */ -type AssertNoUnorderedProvider = T; -export type UsageProviderOrderIsExhaustive = AssertNoUnorderedProvider< - Exclude ->; - -export const PROVIDER_LABEL: Record = { - claude: "Claude Code", - codex: "Codex", - grok: "Grok", - kimi: "Kimi", +type UsageProviderPresentation = { + readonly label: string; + readonly color: string; + readonly mark: Icon; }; /** - * Claude's brand orange against a neutral white for Codex, with Grok and Kimi - * on their own brand hues. All four have to stay distinguishable side by side - * in a stacked band, so Kimi's warmer orange is kept clear of Claude's. - */ -export const PROVIDER_COLOR: Record = { - claude: "#d97757", - codex: "#e6e6e6", - grok: "#8b8b8b", - kimi: "#ff6a3d", -}; - -/** - * Brand marks, reused from the provider picker. + * Exhaustive presentation for providers supported by the usage contract. + * Declaration order is reused by every chart, table, legend, and skeleton, so + * adding a provider only requires its contract support and one entry here. * - * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI), - * which are the same colours as the chart bands, so swapping a colour dot for a - * mark keeps the series association intact rather than trading it away. + * All four have to stay distinguishable side by side in a stacked band, so + * Kimi's warmer orange is kept clear of Claude's. */ -export const PROVIDER_MARK: Record = { - claude: ClaudeAI, - codex: OpenAI, - grok: GrokIcon, - kimi: KimiIcon, -}; +export const PROVIDER_PRESENTATION = { + codex: { + label: "Codex", + color: "var(--foreground)", + mark: OpenAI, + }, + claude: { + label: "Claude Code", + color: "#d97757", + mark: ClaudeAI, + }, + grok: { + label: "Grok", + color: "#8b8b8b", + mark: GrokIcon, + }, + kimi: { + label: "Kimi", + color: "#ff6a3d", + mark: KimiIcon, + }, +} satisfies Record; + +/** The chart layers every series from zero, so order only controls how it is read. */ +export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; diff --git a/apps/web/src/diffFileActions.test.ts b/apps/web/src/diffFileActions.test.ts index 9c358ab1d294..c5d3571a9c1e 100644 --- a/apps/web/src/diffFileActions.test.ts +++ b/apps/web/src/diffFileActions.test.ts @@ -2,7 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { openDiffFilePrimaryAction } from "./diffFileActions"; +import { openDiffFilePrimaryAction, resolveDiffPathForWorkspace } from "./diffFileActions"; import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore"; const THREAD_REF = scopeThreadRef( @@ -48,4 +48,77 @@ describe("openDiffFilePrimaryAction", () => { "/repo/project/apps/web/src/components/DiffPanel.tsx", ); }); + + it("opens repository-relative diff files from a nested project", () => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath: "frontend/Dockerfile", + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ + isOpen: true, + activeSurfaceId: "file:Dockerfile", + }); + expect(openInEditor).not.toHaveBeenCalled(); + }); + + it("preserves repository-relative paths in a separate worktree", () => { + expect( + resolveDiffPathForWorkspace({ + filePath: "frontend/Dockerfile", + workspaceRoot: "/worktrees/feature", + repositoryRoot: "/repo", + }), + ).toBe("frontend/Dockerfile"); + }); + + it("handles Windows roots and mixed diff separators", () => { + expect( + resolveDiffPathForWorkspace({ + filePath: "Frontend/src\\index.ts", + workspaceRoot: "C:\\repo\\frontend", + repositoryRoot: "C:\\repo", + }), + ).toBe("src/index.ts"); + }); + + it.each([ + { workspaceRoot: "/frontend", repositoryRoot: "/" }, + { workspaceRoot: "C:\\frontend", repositoryRoot: "C:\\" }, + ])("handles filesystem roots: $repositoryRoot", ({ workspaceRoot, repositoryRoot }) => { + expect( + resolveDiffPathForWorkspace({ + filePath: "frontend/index.ts", + workspaceRoot, + repositoryRoot, + }), + ).toBe("index.ts"); + }); + + it.each(["backend/server.ts", "frontend2/app.ts", "frontend/../secret.ts", "C:secret.ts"])( + "does not open an out-of-project diff path: %s", + (filePath) => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath, + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ isOpen: false }); + expect(openInEditor).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/web/src/diffFileActions.ts b/apps/web/src/diffFileActions.ts index 335ad21fccf9..3ac22c28cf25 100644 --- a/apps/web/src/diffFileActions.ts +++ b/apps/web/src/diffFileActions.ts @@ -1,4 +1,5 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; +import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { useRightPanelStore } from "./rightPanelStore"; import { resolvePathLinkTarget } from "./terminal-links"; @@ -7,19 +8,93 @@ interface OpenDiffFilePrimaryActionInput { readonly threadRef: ScopedThreadRef | null; readonly filePath: string; readonly activeCwd: string | undefined; + readonly repositoryRoot?: string | undefined; readonly openInEditor: (targetPath: string) => void; } +function normalizedRelativePathSegments(filePath: string): ReadonlyArray | null { + if (filePath.startsWith("/") || isWindowsAbsolutePath(filePath) || /^[a-zA-Z]:/.test(filePath)) { + return null; + } + + const segments = filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0 && segment !== "."); + if (segments.length === 0 || segments.includes("..")) return null; + return segments; +} + +function repositoryRelativeWorkspaceSegments( + workspaceRoot: string | undefined, + repositoryRoot: string | undefined, +): ReadonlyArray | null { + if (!workspaceRoot || !repositoryRoot) return null; + + const normalizedWorkspaceRoot = normalizeProjectPathForComparison(workspaceRoot); + const normalizedRepositoryRoot = normalizeProjectPathForComparison(repositoryRoot); + if (normalizedWorkspaceRoot === normalizedRepositoryRoot) return []; + + const separator = normalizedRepositoryRoot.includes("\\") ? "\\" : "/"; + const repositoryPrefix = normalizedRepositoryRoot.endsWith(separator) + ? normalizedRepositoryRoot + : `${normalizedRepositoryRoot}${separator}`; + if (!normalizedWorkspaceRoot.startsWith(repositoryPrefix)) return null; + + return normalizedWorkspaceRoot + .slice(repositoryPrefix.length) + .split(/[\\/]+/) + .filter(Boolean); +} + +export function resolveDiffPathForWorkspace(input: { + readonly filePath: string; + readonly workspaceRoot: string | undefined; + readonly repositoryRoot: string | undefined; +}): string | null { + const fileSegments = normalizedRelativePathSegments(input.filePath); + if (!fileSegments) return null; + + const workspaceSegments = repositoryRelativeWorkspaceSegments( + input.workspaceRoot, + input.repositoryRoot, + ); + if (!workspaceSegments || workspaceSegments.length === 0) { + return fileSegments.join("/"); + } + + const caseInsensitive = input.repositoryRoot + ? isWindowsAbsolutePath(input.repositoryRoot) + : false; + const belongsToWorkspace = workspaceSegments.every((segment, index) => { + const candidate = fileSegments[index]; + if (candidate === undefined) return false; + return caseInsensitive ? candidate.toLowerCase() === segment : candidate === segment; + }); + if (!belongsToWorkspace) return null; + + const relativeSegments = fileSegments.slice(workspaceSegments.length); + return relativeSegments.length > 0 ? relativeSegments.join("/") : null; +} + export function openDiffFilePrimaryAction({ threadRef, filePath, activeCwd, + repositoryRoot, openInEditor, }: OpenDiffFilePrimaryActionInput): void { + const workspaceFilePath = resolveDiffPathForWorkspace({ + filePath, + workspaceRoot: activeCwd, + repositoryRoot, + }); + if (!workspaceFilePath) return; + if (threadRef) { - useRightPanelStore.getState().openFile(threadRef, filePath); + useRightPanelStore.getState().openFile(threadRef, workspaceFilePath); return; } - openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath); + openInEditor(activeCwd ? resolvePathLinkTarget(workspaceFilePath, activeCwd) : workspaceFilePath); } diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 0129f2d6593d..ef66410f7db4 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -24,6 +24,29 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass()( + "ClipboardReadUnavailableError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `Clipboard API is unavailable while reading ${this.target}.`; + } +} + +export class ClipboardReadError extends Schema.TaggedErrorClass()( + "ClipboardReadError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.target} from the clipboard.`; + } +} + export async function writeTextToClipboard(value: string, target = "text") { if ( typeof window === "undefined" || @@ -48,6 +71,27 @@ export async function writeTextToClipboard(value: string, target = "text") { } } +export async function readTextFromClipboard(target = "text"): Promise { + if ( + typeof window === "undefined" || + typeof navigator === "undefined" || + !navigator.clipboard?.readText + ) { + throw new ClipboardReadUnavailableError({ + target, + }); + } + + try { + return await navigator.clipboard.readText(); + } catch (cause) { + throw new ClipboardReadError({ + target, + cause, + }); + } +} + export function useCopyToClipboard({ timeout = 2000, target = "text", diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index d7ca2305163f..4a25df47b027 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -72,6 +72,7 @@ export function useThreadActionMenu(input: { unsnoozeThread, pinThread, unpinThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -82,6 +83,7 @@ export function useThreadActionMenu(input: { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { @@ -139,6 +141,7 @@ export function useThreadActionMenu(input: { isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, + isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, supports, snoozePresets, }); @@ -253,6 +256,27 @@ export function useThreadActionMenu(input: { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast( + didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", + squashAtomCommandFailure(result), + ); + } + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -285,9 +309,11 @@ export function useThreadActionMenu(input: { })(); }, [ + archiveThread, autoSettleAfterDays, autoSettleOnMerge, changeRequestState, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b9e7027c9143..1c8665774f26 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -716,27 +716,31 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil .chat-composer-glass-shell-with-context::before { border-radius: 0; /* - * One continuous glass layer: a 22px composer joined to a 16px strip, - * whose visible sides align with the composer's bottom tangents. + * One continuous glass layer: a 22px composer joined to a 16px strip. The + * strip is inset 1.375rem per side, so the step-in positions and their + * curve controls stay in rem to keep tracking it at non-default interface + * font sizes; the composer's 22px top radius and the strip's 16px bottom + * radius are px by design. */ clip-path: shape( from 0 22px, curve to 22px 0 with 0 9.85px / 9.85px 0, line to calc(100% - 22px) 0, curve to 100% 22px with calc(100% - 9.85px) 0 / 100% 9.85px, - line to 100% calc(100% - var(--chat-composer-context-extension) - 22px), - curve to calc(100% - 22px) calc(100% - var(--chat-composer-context-extension)) with 100% - calc(100% - var(--chat-composer-context-extension) - 9.85px) / calc(100% - 9.85px) + line to 100% calc(100% - var(--chat-composer-context-extension) - 1.375rem), + curve to calc(100% - 1.375rem) calc(100% - var(--chat-composer-context-extension)) with 100% + calc(100% - var(--chat-composer-context-extension) - 0.6156rem) / calc(100% - 0.6156rem) calc(100% - var(--chat-composer-context-extension)), - line to calc(100% - 22px) calc(100% - 16px), - curve to calc(100% - 38px) 100% with calc(100% - 22px) calc(100% - 7.16px) / - calc(100% - 29.16px) 100%, - line to 38px 100%, - curve to 22px calc(100% - 16px) with 29.16px 100% / 22px calc(100% - 7.16px), - line to 22px calc(100% - var(--chat-composer-context-extension)), - curve to 0 calc(100% - var(--chat-composer-context-extension) - 22px) with 9.85px + line to calc(100% - 1.375rem) calc(100% - 16px), + curve to calc(100% - 1.375rem - 16px) 100% with calc(100% - 1.375rem) calc(100% - 7.16px) / + calc(100% - 1.375rem - 7.16px) 100%, + line to calc(1.375rem + 16px) 100%, + curve to 1.375rem calc(100% - 16px) with calc(1.375rem + 7.16px) 100% / 1.375rem + calc(100% - 7.16px), + line to 1.375rem calc(100% - var(--chat-composer-context-extension)), + curve to 0 calc(100% - var(--chat-composer-context-extension) - 1.375rem) with 0.6156rem calc(100% - var(--chat-composer-context-extension)) / 0 - calc(100% - var(--chat-composer-context-extension) - 9.85px), + calc(100% - var(--chat-composer-context-extension) - 0.6156rem), line to 0 22px, close ); @@ -1630,12 +1634,22 @@ code { } .chat-markdown ul { + /* Reset for nested uls under a widened ol — --list-gutter is an inherited + custom property, so without this a task-list under a 3+ digit ordered + list would inherit the outer gutter instead of its own default. */ + --list-gutter: 1.25rem; padding-left: 1.25rem; list-style-type: disc; } +/* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but + ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose + last marker is 3+ digits, so item 100+ isn't clipped by list-style-position: + outside painting the marker past the padding box. Reset it here too so a + nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { - padding-left: 1.25rem; + --list-gutter: 1.25rem; + padding-left: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -1665,7 +1679,7 @@ code { } .chat-markdown li.task-list-item input[type="checkbox"] { - margin: 0 0.35em 0.15em -1.25rem; + margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem)); vertical-align: middle; } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c606d45c99cb..86dbca7f8853 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -681,6 +681,22 @@ describe("resolveShortcutCommand", () => { ); }); + it("resolves a custom right panel maximize binding", () => { + const keybindings = compile([ + { + shortcut: modShortcut("m", { shiftKey: true }), + command: "rightPanel.toggleMaximized", + }, + ]); + + assert.strictEqual( + resolveShortcutCommand(event({ key: "m", metaKey: true, shiftKey: true }), keybindings, { + platform: "MacIntel", + }), + "rightPanel.toggleMaximized", + ); + }); + it("matches bracket shortcuts using the physical key code", () => { assert.strictEqual( resolveShortcutCommand( @@ -714,6 +730,35 @@ describe("resolveShortcutCommand", () => { "rightPanel.toggle", ); }); + + it("matches non-Latin layout letters using the physical key code", () => { + const keybindings = compile([{ shortcut: modShortcut("d"), command: "diff.toggle" }]); + + assert.strictEqual( + resolveShortcutCommand(event({ key: "в", code: "KeyD", metaKey: true }), keybindings, { + platform: "MacIntel", + }), + "diff.toggle", + ); + }); + + it("ignores the physical key code when the layout types a different Latin letter", () => { + const keybindings = compile([{ shortcut: modShortcut("d"), command: "diff.toggle" }]); + + // On a remapped layout the physical D key types "a"; only the physical + // key whose layout output is "d" may trigger the shortcut. + assert.isNull( + resolveShortcutCommand(event({ key: "a", code: "KeyD", metaKey: true }), keybindings, { + platform: "MacIntel", + }), + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "d", code: "KeyL", metaKey: true }), keybindings, { + platform: "MacIntel", + }), + "diff.toggle", + ); + }); }); describe("formatShortcutLabel", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 9d6109a77806..6ec9a6fab858 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -71,9 +71,15 @@ function normalizeEventKey(key: string): string { } function resolveEventKeys(event: ShortcutEventLike): Set { - const keys = new Set([normalizeEventKey(event.key)]); + const layoutKey = normalizeEventKey(event.key); + const keys = new Set([layoutKey]); + // The physical-position fallback exists for layouts that type non-Latin + // letters (Cyrillic, Greek) and for Option-modified symbols on macOS. + // When the layout already produces a Latin letter, match on it alone; + // otherwise a remapped physical key triggers shortcuts for two different + // letters at once and shadows system shortcuts on non-QWERTY layouts. const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]; - if (letterCode) { + if (letterCode && !/^[a-z]$/.test(layoutKey)) { keys.add(letterCode.toLowerCase()); } const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined; diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts new file mode 100644 index 000000000000..7265e8b60430 --- /dev/null +++ b/apps/web/src/markdown-clipboard.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; + +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + +class FakeText { + readonly nodeType = TEXT_NODE; + readonly childNodes: ReadonlyArray = []; + + constructor(readonly textContent: string) {} +} + +class FakeElement { + readonly nodeType = ELEMENT_NODE; + readonly childNodes: Array = []; + readonly classList = { + contains: (name: string) => this.classNames.includes(name), + }; + + constructor( + readonly tagName: string, + private readonly classNames: ReadonlyArray = [], + ) {} + + get localName(): string { + return this.tagName.toLowerCase(); + } + + get textContent(): string { + return this.childNodes.map((child) => child.textContent).join(""); + } + + append(...children: Array): this { + this.childNodes.push(...children); + return this; + } + + getAttribute(): string | null { + return null; + } + + hasAttribute(): boolean { + return false; + } +} + +function asNode(element: FakeElement): Node { + return element as unknown as Node; +} + +function shikiCodeLine(text: string): FakeElement { + const token = new FakeElement("SPAN").append(new FakeText(text)); + return new FakeElement("SPAN", ["line"]).append(token); +} + +describe("serializeRenderedMarkdownFragment", () => { + beforeEach(() => { + vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("wraps inline code in backticks", () => { + const paragraph = new FakeElement("P").append( + new FakeText("run "), + new FakeElement("CODE").append(new FakeText("git status")), + new FakeText(" first"), + ); + const container = new FakeElement("DIV").append(paragraph); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first"); + }); + + it("keeps a highlighted block code selection plain when its pre wrapper is outside the range", () => { + const code = new FakeElement("CODE").append( + shikiCodeLine("git show-ref --verify refs/remotes/origin/opt/deploy/dev"), + ); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "git show-ref --verify refs/remotes/origin/opt/deploy/dev", + ); + }); + + it("keeps a multi-line code selection plain instead of inline-wrapping it", () => { + const code = new FakeElement("CODE").append(new FakeText("first line\nsecond line")); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 86965eebfa96..069d161a188c 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string { return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`; } +/** + * A code element whose pre wrapper fell outside the copied range is still + * block code, recognizable by its highlighter line spans or embedded + * newlines. Wrapping it like inline code produces backtick-surrounded + * shell commands on paste. + */ +function isBlockCodeElement(element: Element, content: string): boolean { + if (content.includes("\n")) return true; + for (const child of element.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) { + return true; + } + } + return false; +} + function wrapInlineCode(code: string): string { const longestRun = [...(code.match(/`+/g) ?? [])].reduce( (max, run) => Math.max(max, run.length), @@ -201,8 +217,10 @@ function serializeNode(node: Node): string { return `${serializeChildren(element).trim()}\n\n`; case "PRE": return serializeCodeBlock(element); - case "CODE": - return wrapInlineCode(element.textContent ?? ""); + case "CODE": { + const content = element.textContent ?? ""; + return isBlockCodeElement(element, content) ? content : wrapInlineCode(content); + } case "STRONG": case "B": return wrapInlineMarker(serializeChildren(element), "**"); @@ -301,6 +319,17 @@ export function chatMarkdownClipboardPayload( if (range.collapsed) continue; const container = document.createElement("div"); container.appendChild(range.cloneContents()); + const ancestor = range.commonAncestorContainer; + const ancestorElement = + ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement; + if (ancestorElement?.closest("pre")) { + const text = range.toString(); + if (text) { + texts.push(text); + htmls.push(sanitizedHtmlFrom(container)); + } + continue; + } const text = serializeRenderedMarkdownFragment(container); if (!text) continue; texts.push(text); diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc296138672..f7c507c178f9 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -273,3 +273,28 @@ describe("resolveInlineCodeFileLinkMeta", () => { expect(resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md")).toBeNull(); }); }); + +describe("directory paths with a trailing separator", () => { + it("keeps the final segment for a POSIX directory path", () => { + expect(resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project")).toMatchObject({ + basename: "favicons", + }); + }); + + it("keeps the final segment for a Windows directory path", () => { + expect( + resolveMarkdownFileLinkMeta("C:\\Users\\kelchm\\.claude\\", "/repo/project"), + ).toMatchObject({ basename: ".claude" }); + }); + + it("matches the label of the same path without a trailing separator", () => { + const withSlash = resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project"); + const withoutSlash = resolveMarkdownFileLinkMeta("/tmp/favicons", "/repo/project"); + expect(withSlash?.basename).toBe(withoutSlash?.basename); + }); + + it("does not produce an empty label for the filesystem root", () => { + const meta = resolveMarkdownFileLinkMeta("/tmp/", "/repo/project"); + expect(meta?.basename).not.toBe(""); + }); +}); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8ac..e74bd170117f 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -359,8 +359,12 @@ export function resolveInlineCodeFileLinkMeta( } function basenameOfPath(path: string): string { - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; + // A trailing separator is a valid way to write a directory, so trim it before + // taking the final segment. Without this the segment reads as empty and the + // chip renders with no label at all. + const trimmed = path.replace(/[/\\]+$/, "") || path; + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; } function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 337e68d44d0a..fd4ca7da92da 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -109,6 +109,23 @@ function driverKindLabel(driverKind: ProviderDriverKind): string { return PROVIDER_DISPLAY_NAMES[driverKind] ?? formatProviderDriverKindLabel(driverKind); } +/** + * Whether an instance's icon carries the account badge: accent color set, or + * several instances sharing a driver so the brand glyph alone is ambiguous. + * Shared by the composer trigger, the picker rail, and sidebar rows. + */ +export function shouldShowInstanceBadge( + entry: ProviderInstanceEntry, + entries: Iterable, +): boolean { + if (entry.accentColor) return true; + let sharedDriverCount = 0; + for (const candidate of entries) { + if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true; + } + return false; +} + export function normalizeProviderAccentColor(value: string | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index 7ce319973511..41f75eb384f1 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -1,6 +1,15 @@ import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs"; +import type { PullRequestReviewPosition } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +const ReviewCommentSelectionSchema = Schema.Struct({ + start: Schema.Number, + side: Schema.Literals(["additions", "deletions"]), + end: Schema.Number, + endSide: Schema.Literals(["additions", "deletions"]), +}); +type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type; + export const ReviewCommentContextSchema = Schema.Struct({ id: Schema.String, sectionId: Schema.String, @@ -12,6 +21,7 @@ export const ReviewCommentContextSchema = Schema.Struct({ text: Schema.String, diff: Schema.String, fenceLanguage: Schema.optional(Schema.String), + selection: Schema.optional(ReviewCommentSelectionSchema), }); export interface ReviewCommentContext { @@ -25,6 +35,7 @@ export interface ReviewCommentContext { readonly text: string; readonly diff: string; readonly fenceLanguage?: string | undefined; + readonly selection?: ReviewCommentSelection | undefined; } interface DiffReviewLine { @@ -267,10 +278,44 @@ function stripTrailingNewline(value: string): string { return value.endsWith("\n") ? value.slice(0, -1) : value; } -function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray { +function buildDiffReviewLines( + fileDiff: FileDiffMetadata, + includeExpandedContext: boolean, + slice?: { readonly startIndex: number; readonly endIndex: number }, +): ReadonlyArray { const rows: DiffReviewLine[] = []; + let rowIndex = 0; + let oldContextStart = 1; + let newContextStart = 1; + const pushRow = (row: DiffReviewLine) => { + if (!slice || (rowIndex >= slice.startIndex && rowIndex <= slice.endIndex)) { + rows.push(row); + } + rowIndex += 1; + }; + const pushContextGap = (oldStart: number, newStart: number, lineCount: number) => { + const count = Math.max(0, lineCount); + const firstOffset = slice ? Math.max(0, slice.startIndex - rowIndex) : 0; + const lastOffset = slice ? Math.min(count - 1, slice.endIndex - rowIndex) : count - 1; + for (let offset = firstOffset; offset <= lastOffset; offset += 1) { + rows.push({ + change: "context", + oldLineNumber: oldStart + offset, + newLineNumber: newStart + offset, + content: stripTrailingNewline(fileDiff.additionLines[newStart + offset - 1] ?? ""), + }); + } + rowIndex += count; + }; for (const hunk of fileDiff.hunks) { + if (includeExpandedContext) { + const oldHunkStart = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); + const newHunkStart = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); + const contextLines = Math.min(oldHunkStart - oldContextStart, newHunkStart - newContextStart); + pushContextGap(oldContextStart, newContextStart, contextLines); + } + let oldLineNumber = hunk.deletionStart; let newLineNumber = hunk.additionStart; let deletionLineIndex = hunk.deletionLineIndex; @@ -279,7 +324,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray, + fileDiff: FileDiffMetadata, lineNumber: number, side: SelectionSide | undefined, + includeExpandedContext = !fileDiff.isPartial, ): number { - const preferredKey = side === "deletions" ? "oldLineNumber" : "newLineNumber"; - const preferredIndex = lines.findIndex((line) => line[preferredKey] === lineNumber); - if (preferredIndex >= 0) return preferredIndex; - const fallbackKey = preferredKey === "oldLineNumber" ? "newLineNumber" : "oldLineNumber"; - return lines.findIndex((line) => line[fallbackKey] === lineNumber); + const findOnSide = (selectedSide: "left" | "right") => { + let rowIndex = 0; + let oldContextStart = 1; + let newContextStart = 1; + const findContextIndex = (oldStart: number, newStart: number, lineCount: number) => { + const count = Math.max(0, lineCount); + const selectedStart = selectedSide === "left" ? oldStart : newStart; + const offset = lineNumber - selectedStart; + return offset >= 0 && offset < count ? rowIndex + offset : -1; + }; + + for (const hunk of fileDiff.hunks) { + if (includeExpandedContext) { + const oldContextEnd = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); + const newContextEnd = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); + const contextLines = Math.min( + oldContextEnd - oldContextStart, + newContextEnd - newContextStart, + ); + const contextIndex = findContextIndex(oldContextStart, newContextStart, contextLines); + if (contextIndex >= 0) return contextIndex; + rowIndex += Math.max(0, contextLines); + } + + let oldLineNumber = hunk.deletionStart; + let newLineNumber = hunk.additionStart; + for (const segment of hunk.hunkContent) { + if (segment.type === "context") { + const contextIndex = findContextIndex(oldLineNumber, newLineNumber, segment.lines); + if (contextIndex >= 0) return contextIndex; + rowIndex += segment.lines; + oldLineNumber += segment.lines; + newLineNumber += segment.lines; + continue; + } + + if ( + selectedSide === "left" && + lineNumber >= oldLineNumber && + lineNumber < oldLineNumber + segment.deletions + ) { + return rowIndex + lineNumber - oldLineNumber; + } + rowIndex += segment.deletions; + oldLineNumber += segment.deletions; + + if ( + selectedSide === "right" && + lineNumber >= newLineNumber && + lineNumber < newLineNumber + segment.additions + ) { + return rowIndex + lineNumber - newLineNumber; + } + rowIndex += segment.additions; + newLineNumber += segment.additions; + } + + oldContextStart = hunk.deletionStart + hunk.deletionCount; + newContextStart = hunk.additionStart + hunk.additionCount; + if (hunk.deletionCount === 0) oldContextStart += 1; + if (hunk.additionCount === 0) newContextStart += 1; + } + + if (!includeExpandedContext) return -1; + const trailingLines = Math.min( + fileDiff.deletionLines.length - oldContextStart + 1, + fileDiff.additionLines.length - newContextStart + 1, + ); + return findContextIndex(oldContextStart, newContextStart, trailingLines); + }; + + const selectedSide = side === "deletions" ? "left" : "right"; + const preferredIndex = findOnSide(selectedSide); + return preferredIndex >= 0 + ? preferredIndex + : findOnSide(selectedSide === "left" ? "right" : "left"); +} + +/** Resolve the host-facing coordinates of a line selected in the diff viewer. */ +export function resolveDiffReviewPosition( + fileDiff: FileDiffMetadata, + lineNumber: number, + side: SelectionSide | undefined, +): PullRequestReviewPosition | null { + const lineIndex = findDiffReviewLineIndex(fileDiff, lineNumber, side); + if (lineIndex < 0) return null; + const line = buildDiffReviewLines(fileDiff, !fileDiff.isPartial, { + startIndex: lineIndex, + endIndex: lineIndex, + })[0]; + if (line === undefined) return null; + + switch (line.change) { + case "add": + return line.newLineNumber === null ? null : { kind: "added", newLine: line.newLineNumber }; + case "delete": + return line.oldLineNumber === null ? null : { kind: "deleted", oldLine: line.oldLineNumber }; + case "context": + return line.oldLineNumber === null || line.newLineNumber === null + ? null + : { + kind: "context", + oldLine: line.oldLineNumber, + newLine: line.newLineNumber, + side: side === "deletions" ? "left" : "right", + }; + } } function getDiffRange( @@ -416,18 +588,27 @@ export function buildDiffReviewComment(input: { range: SelectedLineRange; text: string; }): ReviewCommentContext | null { - const lines = buildDiffReviewLines(input.fileDiff); - const startIndex = findDiffReviewLineIndex(lines, input.range.start, input.range.side); + const includeExpandedContext = !input.fileDiff.isPartial; + const startIndex = findDiffReviewLineIndex( + input.fileDiff, + input.range.start, + input.range.side, + includeExpandedContext, + ); const endIndex = findDiffReviewLineIndex( - lines, + input.fileDiff, input.range.end, input.range.endSide ?? input.range.side, + includeExpandedContext, ); if (startIndex < 0 || endIndex < 0) return null; const normalizedStartIndex = Math.min(startIndex, endIndex); const normalizedEndIndex = Math.max(startIndex, endIndex); - const selectedLines = lines.slice(normalizedStartIndex, normalizedEndIndex + 1); + const selectedLines = buildDiffReviewLines(input.fileDiff, includeExpandedContext, { + startIndex: normalizedStartIndex, + endIndex: normalizedEndIndex, + }); const oldRange = getDiffRange(selectedLines, "oldLineNumber"); const newRange = getDiffRange(selectedLines, "newLineNumber"); @@ -445,6 +626,12 @@ export function buildDiffReviewComment(input: { ...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`), ].join("\n"), fenceLanguage: "diff", + selection: { + start: input.range.start, + side: input.range.side ?? "additions", + end: input.range.end, + endSide: input.range.endSide ?? input.range.side ?? "additions", + }, }; } diff --git a/apps/web/src/session-logic.command-output.test.ts b/apps/web/src/session-logic.command-output.test.ts new file mode 100644 index 000000000000..570629046a60 --- /dev/null +++ b/apps/web/src/session-logic.command-output.test.ts @@ -0,0 +1,85 @@ +import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { deriveWorkLogEntries } from "./session-logic"; + +function makeCommandActivity( + id: string, + payload: Record, +): OrchestrationThreadActivity { + return { + id: EventId.make(id), + createdAt: "2026-07-17T10:00:00.000Z", + kind: "tool.completed", + summary: "Ran command", + tone: "tool", + payload, + turnId: TurnId.make("turn-1"), + }; +} + +describe("deriveWorkLogEntries command output", () => { + it("uses Codex aggregated output instead of repeating the command", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("codex-command", { + itemType: "command_execution", + title: "Ran command", + detail: "/bin/zsh -lc \"printf 'hello\\n'\"", + data: { + item: { + type: "commandExecution", + command: "/bin/zsh -lc \"printf 'hello\\n'\"", + commandActions: [{ command: "printf 'hello\\n'", type: "unknown" }], + aggregatedOutput: "hello\n", + status: "completed", + }, + }, + }), + ]); + + expect(entry).toMatchObject({ + command: "printf 'hello\\n'", + rawCommand: "/bin/zsh -lc \"printf 'hello\\n'\"", + detail: "hello", + }); + }); + + it("uses a projected Claude output summary instead of repeating the command", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("claude-command", { + itemType: "command_execution", + title: "Ran command", + detail: "printf hello", + data: { + kind: "execute", + command: "printf hello", + rawOutput: { + content: "hello from claude", + }, + }, + }), + ]); + + expect(entry).toMatchObject({ + command: "printf hello", + detail: "hello from claude", + }); + }); + + it("drops duplicated command detail when the command has no output", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("empty-command", { + itemType: "command_execution", + title: "Ran command", + detail: "true", + data: { + kind: "execute", + command: "true", + }, + }), + ]); + + expect(entry?.command).toBe("true"); + expect(entry?.detail).toBeUndefined(); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 3532f43b7aeb..a2d9769c8848 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1622,6 +1622,70 @@ function summarizeToolRawOutput(payload: Record | null): string return null; } +function extractAcpTextContent(value: unknown): string | null { + if (!Array.isArray(value)) { + return null; + } + + const chunks: string[] = []; + for (const entryValue of value) { + const entry = asRecord(entryValue); + if (entry?.type !== "content") { + continue; + } + const content = asRecord(entry.content); + if (content?.type !== "text") { + continue; + } + const text = asTrimmedString(content.text); + if (text) { + chunks.push(text); + } + } + + return chunks.length > 0 ? chunks.join("\n") : null; +} + +function extractToolOutput(payload: Record | null): string | null { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const rawOutput = asRecord(data?.rawOutput); + + const outputStreams: string[] = []; + const stdout = asTrimmedString(rawOutput?.stdout); + const stderr = asTrimmedString(rawOutput?.stderr); + if (stdout) { + outputStreams.push(stdout); + } + if (stderr) { + outputStreams.push(stderr); + } + + const candidates: unknown[] = [ + item?.aggregatedOutput, + itemResult?.content, + data?.rawOutput, + rawOutput?.content, + outputStreams.length > 0 ? outputStreams.join("\n") : null, + rawOutput?.output, + extractAcpTextContent(data?.content), + ]; + + for (const candidate of candidates) { + const text = asTrimmedString(candidate); + if (!text) { + continue; + } + const output = stripTrailingExitCode(text).output; + if (output) { + return output; + } + } + + return null; +} + function isCommandToolDetail(payload: Record | null, heading: string): boolean { const data = asRecord(payload?.data); const kind = asTrimmedString(data?.kind)?.toLowerCase(); @@ -1642,12 +1706,37 @@ function extractToolDetail( const detail = rawDetail ? stripTrailingExitCode(rawDetail).output : null; const normalizedHeading = normalizePreviewForComparison(heading); const normalizedDetail = normalizePreviewForComparison(detail); + const commandTool = isCommandToolDetail(payload, heading); + const commandPreview = commandTool + ? extractToolCommand(payload) + : { command: null, rawCommand: null }; + const command = commandPreview.command; + const normalizedCommand = normalizePreviewForComparison(command); + const normalizedRawCommand = normalizePreviewForComparison(commandPreview.rawCommand); - if (detail && normalizedHeading !== normalizedDetail) { + if ( + detail && + normalizedHeading !== normalizedDetail && + (!commandTool || + (normalizedCommand !== normalizedDetail && normalizedRawCommand !== normalizedDetail)) + ) { return detail; } - if (isCommandToolDetail(payload, heading)) { + if (commandTool) { + if (!command) { + return null; + } + + const output = extractToolOutput(payload); + const normalizedOutput = normalizePreviewForComparison(output); + if ( + output && + normalizedOutput !== normalizedHeading && + normalizedOutput !== normalizedCommand + ) { + return output; + } return null; } diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 18cf95901209..c11529e0c46c 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -253,6 +253,22 @@ describe("isTerminalPasteShortcut", () => { true, ); }); + + it("supports the conventional Shift+Insert paste shortcut", () => { + expect(isTerminalPasteShortcut(event({ key: "Insert", shiftKey: true }), "Linux x86_64")).toBe( + true, + ); + expect(isTerminalPasteShortcut(event({ key: "Insert" }), "Linux x86_64")).toBe(false); + expect( + isTerminalPasteShortcut( + event({ key: "Insert", ctrlKey: true, shiftKey: true }), + "Linux x86_64", + ), + ).toBe(false); + expect(isTerminalPasteShortcut(event({ key: "Insert", shiftKey: true }), "MacIntel")).toBe( + false, + ); + }); }); describe("isTerminalCompositionCommitInput", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 0bb33875568f..9492e2d02628 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -340,7 +340,11 @@ export function isTerminalPasteShortcut( event: Pick, platform = navigator.platform, ) { - if (event.key.toLowerCase() !== "v") return false; + const key = event.key.toLowerCase(); + if (key === "insert" && !isMacPlatform(platform)) { + return event.shiftKey && !event.ctrlKey && !event.metaKey; + } + if (key !== "v") return false; return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } @@ -465,6 +469,12 @@ export interface GhosttyTerminalSurfaceOptions { readonly onSelectionChange: () => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; + /** + * A right-click the running application did not claim through mouse + * reporting. The host owns the menu, so it also owns preventing the browser + * default — whose Paste entry can never reach a canvas terminal. + */ + readonly onContextMenu?: (event: MouseEvent) => void; } export class GhosttyTerminalSurface { @@ -801,6 +811,28 @@ export class GhosttyTerminalSurface { this.input.focus({ preventScroll: true }); } + /** + * Pastes clipboard text read by the host (context menu) with the same + * bracketed-paste encoding as a native paste event. The read joins the same + * race the paste shortcut uses — the token is claimed before it starts — so + * a shortcut or native paste arriving during the read supersedes this one + * instead of both reaching the shell. + */ + async pasteFromClipboard( + readText: () => Promise, + isCurrent: () => boolean = () => true, + ): Promise { + const token = ++this.pasteShortcutToken; + const text = await readText(); + if (this.disposed || this.pasteShortcutToken !== token || !isCurrent()) return; + // As in every paste path, delivering bumps the token so a clipboard read + // still in flight cannot land after this text reaches the shell. + this.pasteShortcutToken += 1; + if (text.length === 0) return; + const encoded = this.core.encodePaste(text); + if (encoded.length > 0) this.options.onData(encoded); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1373,7 +1405,9 @@ export class GhosttyTerminalSurface { private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); + return; } + this.options.onContextMenu?.(event); }; private readonly onScrollbarPointerDown = (event: PointerEvent) => { diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 6678549ccd90..f35c1c1fdbf8 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -12,6 +12,7 @@ import { formatTimestamp, getRelativeTimeState, getTimestampFormatOptions, + resolveTimestampLocale, } from "./timestampFormat"; describe("getTimestampFormatOptions", () => { @@ -41,6 +42,40 @@ describe("getTimestampFormatOptions", () => { }); }); +describe("resolveTimestampLocale", () => { + it("defers to the runtime default when the host reports no locale", () => { + expect(resolveTimestampLocale(null)).toBeUndefined(); + expect(resolveTimestampLocale(undefined)).toBeUndefined(); + expect(resolveTimestampLocale(" ")).toBeUndefined(); + }); + + it("uses a BCP-47 tag reported by the host", () => { + expect(resolveTimestampLocale("en-GB")).toBe("en-GB"); + }); + + it("defers to the runtime default rather than throwing on an unusable tag", () => { + // The desktop bridge normalizes POSIX identifiers before reporting them, so + // anything Intl still rejects here falls back instead of breaking every + // timestamp in the UI. + expect(resolveTimestampLocale("not a locale")).toBeUndefined(); + expect(resolveTimestampLocale("en_GB")).toBeUndefined(); + }); + + it("renders the host locale's hour cycle under the locale setting", () => { + const formatAt1544 = (systemLocale: string | null) => + new Intl.DateTimeFormat(resolveTimestampLocale(systemLocale), { + ...getTimestampFormatOptions("locale", false), + timeZone: "UTC", + }) + .format(new Date("2026-04-07T15:44:00.000Z")) + // ICU separates the day period with a narrow no-break space. + .replace(/[  ]/g, " "); + + expect(formatAt1544("en-GB")).toBe("15:44"); + expect(formatAt1544("en-US")).toBe("3:44 PM"); + }); +}); + describe("formatRelativeTimeUntilLabel", () => { beforeEach(() => { vi.useFakeTimers(); @@ -141,6 +176,20 @@ describe("formatDayAwareTimestamp", () => { ); }); + it("uses the host locale for both the numeric date and wall-clock time", async () => { + vi.stubGlobal("window", { + desktopBridge: { getSystemLocale: () => "en-GB" }, + }); + vi.resetModules(); + + const { formatDayAwareTimestamp: formatWithHostLocale } = await import("./timestampFormat"); + const messageAt = iso(2026, 7, 12, 15, 44); + + expect(formatWithHostLocale(messageAt, "locale", now)).toBe("12/08 15:44"); + + vi.unstubAllGlobals(); + }); + it("returns an empty string for invalid input", () => { expect(formatDayAwareTimestamp("not-a-date", "12-hour", now)).toBe(""); }); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index c8f9956ebb3f..c1c30a544573 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -20,6 +20,39 @@ export function getTimestampFormatOptions( }; } +/** + * Pick the locale to format wall-clock times in, given the locale the host + * reports. Hosts that report nothing fall back to `undefined`, which is the + * runtime default and the right answer in a browser. + * + * A host reports a locale only when it knows better than the runtime does — + * see `getSystemLocale` on the desktop bridge for why desktop does. + */ +export function resolveTimestampLocale( + systemLocale: string | null | undefined, +): string | undefined { + const tag = systemLocale?.trim(); + if (!tag) return undefined; + + try { + // Every timestamp in the UI runs through this formatter, so a tag the host + // could not normalize falls back rather than throwing. Throws on a + // structurally invalid tag; a well-formed tag ICU has no data for resolves + // here and is left to ICU's own fallback. + Intl.DateTimeFormat.supportedLocalesOf([tag]); + return tag; + } catch { + return undefined; + } +} + +function readHostSystemLocale(): string | null { + if (typeof window === "undefined") return null; + return window.desktopBridge?.getSystemLocale?.() ?? null; +} + +const timestampLocale = resolveTimestampLocale(readHostSystemLocale()); + const timestampFormatterCache = new Map(); function getTimestampFormatter( @@ -33,7 +66,7 @@ function getTimestampFormatter( } const formatter = new Intl.DateTimeFormat( - undefined, + timestampLocale, getTimestampFormatOptions(timestampFormat, includeSeconds), ); timestampFormatterCache.set(cacheKey, formatter); @@ -51,6 +84,9 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma return getTimestampFormatter(timestampFormat, true).format(date); } +// Deliberately not the host locale: the tooltip's ordinal suffix and +// day-before-month order below are English, so a localized month alone would +// read "4th Juni 2026". Localizing the whole label is a separate change. const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); function ordinalSuffix(day: number): string { @@ -91,11 +127,11 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp return getTimestampFormatter(timestampFormat, false).format(date); } -const numericDateFormatter = new Intl.DateTimeFormat(undefined, { +const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, { month: "numeric", day: "numeric", }); -const numericDateWithYearFormatter = new Intl.DateTimeFormat(undefined, { +const numericDateWithYearFormatter = new Intl.DateTimeFormat(timestampLocale, { month: "numeric", day: "numeric", year: "numeric", diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts new file mode 100644 index 000000000000..e96e5f18b4f7 --- /dev/null +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + claimWorkspaceBasenameLookup, + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, +} from "./workspaceBasenameLookup"; + +describe("needsWorkspaceBasenameLookup", () => { + it("flags bare filenames", () => { + expect(needsWorkspaceBasenameLookup("ChatView.tsx")).toBe(true); + expect(needsWorkspaceBasenameLookup("Makefile")).toBe(true); + }); + + it("leaves anything with a directory alone", () => { + expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup(" ")).toBe(false); + }); +}); + +describe("pickWorkspaceBasenameMatch", () => { + const entries = [ + { path: "apps/web/src/components/ChatView.test.tsx", kind: "file" as const }, + { path: "apps/web/src/components/ChatView.tsx", kind: "file" as const }, + ]; + + it("takes the first exact filename match, not the closest fuzzy one", () => { + expect(pickWorkspaceBasenameMatch("ChatView.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("ignores directories", () => { + expect( + pickWorkspaceBasenameMatch("components", [ + { path: "apps/web/src/components", kind: "directory" }, + { path: "apps/web/src/components/components", kind: "file" }, + ]), + ).toBe("apps/web/src/components/components"); + }); + + it("prefers the exactly-cased file over a case-only twin", () => { + expect( + pickWorkspaceBasenameMatch("foo.ts", [ + { path: "src/Foo.ts", kind: "file" }, + { path: "src/foo.ts", kind: "file" }, + ]), + ).toBe("src/foo.ts"); + }); + + it("falls back to case-insensitive when only the casing differs", () => { + expect(pickWorkspaceBasenameMatch("chatview.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("returns null when the case-insensitive fallback is ambiguous", () => { + expect( + pickWorkspaceBasenameMatch("FOO.ts", [ + { path: "src/Foo.ts", kind: "file" }, + { path: "src/foo.ts", kind: "file" }, + ]), + ).toBeNull(); + }); + + it("returns null when nothing matches the name", () => { + expect(pickWorkspaceBasenameMatch("ChatView.tsx", [])).toBeNull(); + expect( + pickWorkspaceBasenameMatch("ChatView.tsx", [ + { path: "apps/web/src/components/ChatHeader.tsx", kind: "file" }, + ]), + ).toBeNull(); + }); +}); + +describe("claimWorkspaceBasenameLookup", () => { + it("keeps only the newest claim, whatever order the lookups settle in", () => { + const first = claimWorkspaceBasenameLookup(); + const second = claimWorkspaceBasenameLookup(); + + // The older lookup answering last must not reopen the panel behind the + // newer one. + expect(second()).toBe(true); + expect(first()).toBe(false); + }); + + it("stays valid while it is the only claim", () => { + const only = claimWorkspaceBasenameLookup(); + expect(only()).toBe(true); + expect(only()).toBe(true); + }); +}); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts new file mode 100644 index 000000000000..b99d3ba4ded9 --- /dev/null +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -0,0 +1,48 @@ +// Enough hits to look past same-named neighbours (`ChatView.test.tsx`) without +// asking for a full listing on a single click. +export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25; + +// One counter for every caller: they all open the same panel, so the newest +// click wins regardless of which one started the lookup. +let latestLookupSequence = 0; + +/** Call the returned predicate when the search settles; false means a later click superseded it. */ +export function claimWorkspaceBasenameLookup(): () => boolean { + latestLookupSequence += 1; + const claimed = latestLookupSequence; + return () => claimed === latestLookupSequence; +} + +export interface WorkspaceEntryCandidate { + readonly path: string; + readonly kind: "file" | "directory"; +} + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +export function needsWorkspaceBasenameLookup(relativePath: string): boolean { + const trimmed = relativePath.trim(); + return trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\"); +} + +export function pickWorkspaceBasenameMatch( + basename: string, + entries: ReadonlyArray, +): string | null { + const target = basename.trim(); + if (!target) return null; + const files = entries.filter((entry) => entry.kind === "file"); + const exact = files.find((entry) => basenameOfPath(entry.path) === target); + if (exact) return exact.path; + // Folded matching covers casing that drifted from disk, but `FOO.ts` against + // both `Foo.ts` and `foo.ts` has no right answer, so it resolves to nothing + // rather than opening whichever the index ranked first. + const folded = target.toLowerCase(); + const foldedMatches = files.filter( + (entry) => basenameOfPath(entry.path).toLowerCase() === folded, + ); + return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null; +} diff --git a/docs/user/composer.md b/docs/user/composer.md new file mode 100644 index 000000000000..d2e49db247b0 --- /dev/null +++ b/docs/user/composer.md @@ -0,0 +1,5 @@ +# Message composer + +Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the +composer and shows how many characters need to be removed. Shorten the draft or split it into +multiple messages, then send again in the same thread. diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index f7f6facbe594..63264bed10b5 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -47,6 +47,9 @@ Use **Inspect** to pick an element in the app and reveal its color token. Inspec successful pick; its hover glow and badge preview the element and token that click will select. **Cancel** or `Escape` exits Inspect and clears its selection and spotlight. +`rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, +so add one in **Settings** → **Keybindings** if you want to use it. + The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while keeping the thread's project, branch, and machine context visible. Message search begins after two diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 79f1211cf40d..f9699388b7db 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -34,6 +34,13 @@ When you set this field, T3 Code points Claude Code at that directory with the `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and the rest of your environment stay as they are. +## Where Claude Skills Are Loaded + +T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then +`/.agents/skills`, then `/.claude/skills`. + +If the same skill name exists in more than one folder, the later folder wins. + ## I Want Work And Personal Claude Accounts Use a different Claude config directory for each account. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2d4258bccd55..7d1522cc51f9 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -103,7 +103,8 @@ export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" ``` Or an Atlassian account email plus API token, with read/write access to pull requests and -repositories: +repositories, plus read access to your user account (`read:user:bitbucket`, used to verify the +connection): ```bash export T3CODE_BITBUCKET_EMAIL="you@example.com" diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 4cca703c145c..60cc1bacdbfa 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -13,6 +13,9 @@ import { canCreateProjectInEnvironment, findExistingAddProject, getAddProjectInitialQuery, + getCloneDestinationBrowsePath, + getCloneDestinationPath, + getCloneDirectoryName, resolveAddProjectPath, sortAddProjectProviderSources, } from "./projects.ts"; @@ -34,6 +37,79 @@ describe("add project shared logic", () => { expect(getAddProjectInitialQuery("C:\\work")).toBe("C:\\work\\"); }); + it("derives the clone folder name from the repository name with owner", () => { + expect(getCloneDirectoryName("owner/repo")).toBe("repo"); + expect(getCloneDirectoryName("org/project/repo")).toBe("repo"); + expect(getCloneDirectoryName("repo")).toBe("repo"); + expect(getCloneDirectoryName("owner/repo/")).toBe("repo"); + expect(getCloneDirectoryName("")).toBe(""); + expect(getCloneDirectoryName(null)).toBe(""); + }); + + it("derives the clone folder name from any pasted clone URL", () => { + expect(getCloneDirectoryName("https://github.com/owner/repo.git")).toBe("repo"); + expect(getCloneDirectoryName("https://github.com/owner/repo")).toBe("repo"); + expect(getCloneDirectoryName("https://github.com/owner/repo/")).toBe("repo"); + expect(getCloneDirectoryName("git@github.com:owner/repo.git")).toBe("repo"); + expect(getCloneDirectoryName("ssh://git@github.com:22/owner/repo.git")).toBe("repo"); + expect(getCloneDirectoryName("https://user@bitbucket.org/owner/repo.git")).toBe("repo"); + expect(getCloneDirectoryName("https://dev.azure.com/org/project/_git/repo")).toBe("repo"); + expect(getCloneDirectoryName("https://github.com/owner/repo.git?ref=main#readme")).toBe("repo"); + expect(getCloneDirectoryName("/srv/git/repo.git")).toBe("repo"); + expect(getCloneDirectoryName("C:\\src\\repo.git")).toBe("repo"); + expect(getCloneDirectoryName("git@github.com:repo.git")).toBe("repo"); + expect(getCloneDirectoryName(" https://github.com/owner/repo.git ")).toBe("repo"); + }); + + it("keeps a numeric repository name that sits on a path", () => { + expect(getCloneDirectoryName("https://github.com/acme/123.git")).toBe("123"); + expect(getCloneDirectoryName("https://github.com/acme/123")).toBe("123"); + expect(getCloneDirectoryName("git@github.com:acme/123.git")).toBe("123"); + }); + + it("proposes no clone folder for a link that names no repository", () => { + expect(getCloneDirectoryName("https://github.com/")).toBe(""); + expect(getCloneDirectoryName("https://github.com")).toBe(""); + expect(getCloneDirectoryName("git@github.com:")).toBe(""); + expect(getCloneDirectoryName("ssh://git@github.com:22")).toBe(""); + expect(getCloneDirectoryName("https://")).toBe(""); + }); + + it("proposes the clone destination inside the selected directory", () => { + expect(getCloneDestinationPath("~/Projects/", "repo")).toBe("~/Projects/repo"); + expect(getCloneDestinationPath("~/Projects", "repo")).toBe("~/Projects/repo"); + expect(getCloneDestinationPath("C:\\work\\", "repo")).toBe("C:\\work\\repo"); + expect(getCloneDestinationPath("~/Projects/", null)).toBe("~/Projects/"); + expect(getCloneDestinationPath("~/Projects/", "")).toBe("~/Projects/"); + }); + + it("keeps pinned clone destinations anchored to the browsed directory", () => { + expect( + getCloneDestinationBrowsePath({ + browseDirectoryPath: "~/Projects/", + selectedDirectoryName: "work", + cloneDirectoryName: "repo", + caseSensitive: true, + }), + ).toBe("~/Projects/work/repo"); + expect( + getCloneDestinationBrowsePath({ + browseDirectoryPath: "~/Projects/", + selectedDirectoryName: "repo", + cloneDirectoryName: "repo", + caseSensitive: true, + }), + ).toBe("~/Projects/repo/"); + expect( + getCloneDestinationBrowsePath({ + browseDirectoryPath: "C:\\Projects\\", + selectedDirectoryName: "Repo", + cloneDirectoryName: "repo", + caseSensitive: false, + }), + ).toBe("C:\\Projects\\Repo\\"); + }); + it("rejects unsupported windows paths on non-windows environments", () => { expect( resolveAddProjectPath({ diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 056f96b21de5..914931689426 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -13,6 +13,7 @@ import * as Option from "effect/Option"; import * as Order from "effect/Order"; import { + appendBrowsePathSegment, ensureBrowseDirectoryPath, findProjectByPath, inferProjectTitleFromPath, @@ -176,6 +177,76 @@ export function getAddProjectInitialQuery(baseDirectory: string | null | undefin return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed); } +/** + * Folder name `git clone` would pick, from either a looked-up repository or a + * pasted clone URL. Providers report `owner/repo`, Azure DevOps reports + * `org/project/repo`, and a URL can arrive in any form: `https://host/owner/ + * repo.git`, `ssh://git@host:22/owner/repo`, `git@host:owner/repo.git`, with + * or without a query, a fragment or a trailing slash. The repository is always + * the last segment, minus the `.git` suffix. + */ +export function getCloneDirectoryName(repositoryOrRemoteUrl: string | null | undefined): string { + const withoutQuery = (repositoryOrRemoteUrl ?? "").split(/[?#]/)[0]?.trim() ?? ""; + const schemeIndex = withoutQuery.indexOf("://"); + // A remote URL carries a host before the repository path. The host is never + // the repository, so a link that stops at the host, or at a port, names + // nothing and the destination falls back to the browsed folder. + const hasHost = schemeIndex >= 0 || /^[^/\\:]+@[^/\\:]+:/.test(withoutQuery); + const pathPart = schemeIndex >= 0 ? withoutQuery.slice(schemeIndex + "://".length) : withoutQuery; + const segments = pathPart.split(/[/\\:]+/).filter((segment) => segment.trim().length > 0); + if (hasHost && segments.length < 2) { + return ""; + } + + const lastSegment = segments.at(-1)?.trim() ?? ""; + // A port can only sit directly behind the authority, so it is a port only + // when nothing follows it. Deeper segments are path, even when numeric: the + // repository in `https://host/acme/123` really is named `123`. + if (hasHost && segments.length === 2 && /^\d+$/.test(lastSegment)) { + return ""; + } + return lastSegment.endsWith(".git") ? lastSegment.slice(0, -".git".length) : lastSegment; +} + +/** + * Clone destination proposed for a directory: the directory the user picked + * plus the repository folder inside it. Without a name the directory is the + * destination, which is what the raw clone URL flow keeps doing. + */ +export function getCloneDestinationPath( + directoryPath: string, + directoryName: string | null | undefined, +): string { + const name = directoryName?.trim() ?? ""; + if (name.length === 0) { + return directoryPath; + } + return `${ensureBrowseDirectoryPath(directoryPath)}${name}`; +} + +/** + * Destination query after choosing a directory while the clone folder is + * pinned in the path input. Selecting an existing directory with the pinned + * name uses that directory directly instead of producing `repo/repo`. + */ +export function getCloneDestinationBrowsePath(input: { + readonly browseDirectoryPath: string; + readonly selectedDirectoryName: string; + readonly cloneDirectoryName: string; + readonly caseSensitive: boolean; +}): string { + const selectedDirectoryPath = appendBrowsePathSegment( + input.browseDirectoryPath, + input.selectedDirectoryName, + ); + const selectedDirectoryMatches = input.caseSensitive + ? input.selectedDirectoryName === input.cloneDirectoryName + : input.selectedDirectoryName.toLowerCase() === input.cloneDirectoryName.toLowerCase(); + return selectedDirectoryMatches + ? selectedDirectoryPath + : getCloneDestinationPath(selectedDirectoryPath, input.cloneDirectoryName); +} + export function resolveAddProjectPath(input: { readonly rawPath: string; readonly currentProjectCwd?: string | null; diff --git a/packages/client-runtime/src/state/projects.ts b/packages/client-runtime/src/state/projects.ts index 31f5ce111991..10ec5025fb9d 100644 --- a/packages/client-runtime/src/state/projects.ts +++ b/packages/client-runtime/src/state/projects.ts @@ -9,7 +9,7 @@ import { export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch }; -const isWindowsPlatform = (platform: string): boolean => { +export const isWindowsPlatform = (platform: string): boolean => { return /^win(dows)?/i.test(platform); }; diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index fbf15f446b67..69b8dc89ef19 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -383,13 +383,49 @@ describe("deriveAgentPanelModel", () => { it("counts idle deliberately and waiting as active", () => { const model = deriveAgentPanelModel({ agents: roster }); expect(model.idleCount).toBe(1); - // wf-1 coordinator + member 1 running. - expect(model.runningCount).toBeGreaterThanOrEqual(1); + // Member 1 is running; the wf-1 coordinator is a container, not a worker. + expect(model.runningCount).toBe(1); + // Every agent lands in exactly one bucket, except coordinators that stand + // in for their members. expect(model.idleCount + model.runningCount + model.waitingCount + model.settledCount).toBe( - roster.length, + roster.length - 1, ); }); + it("omits a workflow coordinator from the working-agent count", () => { + const model = deriveAgentPanelModel({ agents: roster }); + // One member still running plus one idle direct spawn. The coordinator + // reports running for the whole workflow and must not inflate the banner. + expect(model.liveCount).toBe(1); + }); + + it("omits a finished workflow coordinator from the settled count", () => { + const finished = fold([ + activity("task.started", { taskId: "wf-2", taskType: "local_workflow", title: "sweep" }), + activity("task.progress", { + taskId: "wf-2:wf:0", + title: "sweep:a", + status: "completed", + parentAgentId: "wf-2", + agentIndex: 0, + phaseIndex: 0, + }), + activity("task.completed", { + taskId: "wf-2:wf:0", + status: "completed", + parentAgentId: "wf-2", + }), + activity("task.completed", { taskId: "wf-2", status: "completed" }), + ]); + + const model = deriveAgentPanelModel({ agents: finished }); + + // Only the member settled. The coordinator stands in for it, so counting + // both would report two finished agents where one ran. + expect(model.settledCount).toBe(1); + expect(model.liveCount).toBe(0); + }); + it("keeps direct spawns in first-seen order as their activity changes", () => { const directRoster = fold([ activity("task.started", { taskId: "direct-a", title: "First" }, "2026-08-01T11:00:00.000Z"), diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index e5f2b586b8c4..c1ea1cc2b15d 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -826,15 +826,16 @@ export function deriveAgentPanelModel({ let settledCount = 0; let totalTokens = 0; for (const agent of source) { + // A workflow coordinator with members is a container for those members, not + // work of its own: it reports running for the whole run and aggregates their + // usage upstream in some providers. Counting it would report one more agent + // working than there are, and double count tokens. + if (agent.kind === "workflow" && (members.get(agent.id) ?? []).length > 0) continue; if (agent.status === "running" || agent.status === "pending") runningCount += 1; else if (agent.status === "waiting") waitingCount += 1; else if (agent.status === "idle") idleCount += 1; else settledCount += 1; - // Workflow coordinators aggregate member usage upstream in some providers; - // avoid double counting by only summing leaf agents when members exist. - if (agent.kind !== "workflow" || (members.get(agent.id) ?? []).length === 0) { - totalTokens += agent.usage?.totalTokens ?? 0; - } + totalTokens += agent.usage?.totalTokens ?? 0; } return { diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index a75521f6aa9a..a82c3e1d9074 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -240,29 +240,32 @@ export function cachedVcsRefsChanges( export function createVcsEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { - const listRefsByEnvironment = Atom.family((environmentId: EnvironmentId) => - Atom.family((inputKey: string) => { - const input = JSON.parse(inputKey) as VcsListRefsInput; - return runtime - .atom((get) => { - const state = get(vcsRefsCacheStateAtom({ environmentId })); - return cachedVcsRefsChanges( - environmentId, - input, - state.revision, - state.persistedCacheReadable, - ); - }) - .pipe( - Atom.setIdleTTL(VCS_REFS_IDLE_TTL_MS), - Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`), + /** + * One flat family on purpose: families hold entries via WeakRef, so a nested + * per-environment family can be collected between lookups, dropping every + * cached page atom and collapsing paginated ref lists mid-scroll. + */ + const listRefsFamily = Atom.family((key: string) => { + const [environmentId, input] = JSON.parse(key) as [EnvironmentId, VcsListRefsInput]; + return runtime + .atom((get) => { + const state = get(vcsRefsCacheStateAtom({ environmentId })); + return cachedVcsRefsChanges( + environmentId, + input, + state.revision, + state.persistedCacheReadable, ); - }), - ); + }) + .pipe( + Atom.setIdleTTL(VCS_REFS_IDLE_TTL_MS), + Atom.withLabel(`environment-data:vcs:list-refs:${key}`), + ); + }); const listRefs = (target: { readonly environmentId: EnvironmentId; readonly input: VcsListRefsInput; - }) => listRefsByEnvironment(target.environmentId)(JSON.stringify(target.input)); + }) => listRefsFamily(JSON.stringify([target.environmentId, target.input])); const invalidateRefs = ( target: { readonly environmentId: EnvironmentId; readonly input: { readonly cwd: string } }, registry: AtomRegistry.AtomRegistry, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 37b0bf7d26a4..80883fd50509 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1042,6 +1042,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + /** + * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: + * the packaged app ships only the `en-US` Chromium locale pak, so + * `navigator.language` and the default `Intl` locale are pinned to `en-US` + * regardless of OS settings. + */ + getSystemLocale?: () => string | null; // One bootstrap per pool instance currently registered with bootstrap // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. @@ -1104,6 +1111,12 @@ export interface DesktopBridge { */ probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; + /** + * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, + * "up" when it is released before the hold completes. Optional: older + * desktop builds never emit it. + */ + onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 342e44678938..71d8624a8aea 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -42,6 +42,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedRightPanelToggle.command, "rightPanel.toggle"); + const parsedRightPanelToggleMaximized = yield* decode(KeybindingRule, { + key: "mod+shift+m", + command: "rightPanel.toggleMaximized", + }); + assert.strictEqual(parsedRightPanelToggleMaximized.command, "rightPanel.toggleMaximized"); + const parsedClose = yield* decode(KeybindingRule, { key: "mod+w", command: "terminal.close", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index dabb2962f2ff..f38bcbf85dcb 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -47,7 +47,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [ ] as const; export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; -const STATIC_KEYBINDING_COMMANDS = [ +export const STATIC_KEYBINDING_COMMANDS = [ "sidebar.toggle", "terminal.toggle", "terminal.split", @@ -55,6 +55,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "rightPanel.toggle", + "rightPanel.toggleMaximized", "diff.toggle", "preview.toggle", "preview.refresh", diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index b1bf06230e25..0c590c53e073 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -23,6 +23,7 @@ import { ThreadCreatedPayload, ThreadTurnDiff, ThreadTurnStartRequestedPayload, + isProviderSendTurnSupportedImageMimeType, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -937,3 +938,9 @@ it.effect("project favicon overrides accept only supported image files", () => assert.strictEqual(invalid._tag, "Failure"); }), ); + +it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => { + assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true); + assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); + assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false); +}); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1c883908017f..cd1a692d3f38 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -146,6 +146,20 @@ export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; +export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +] as const; +const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPE_SET = new Set( + PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, +); + +/** Whether a pasted or picked image mime type can be sent on a provider turn. */ +export function isProviderSendTurnSupportedImageMimeType(mimeType: string): boolean { + return PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPE_SET.has(mimeType.toLowerCase()); +} const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; const CHAT_ATTACHMENT_ID_MAX_CHARS = 128; // Correlation id is command id by design in this model. diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index b5b7dfa93f77..8d7bcb5ef2f9 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -56,6 +56,7 @@ export const ProviderSessionStartInput = Schema.Struct({ // See ProviderSession for the migration story. providerInstanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), + title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index d1b2ba705f5e..dea49ea8fa59 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -856,6 +856,26 @@ export const PullRequestCommentUpdateInput = Schema.Struct({ }); export type PullRequestCommentUpdateInput = typeof PullRequestCommentUpdateInput.Type; +/** The coordinates of one line in a pull request diff. */ +export const PullRequestReviewPosition = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("added"), + newLine: PositiveInt, + }), + Schema.Struct({ + kind: Schema.Literal("deleted"), + oldLine: PositiveInt, + }), + Schema.Struct({ + kind: Schema.Literal("context"), + oldLine: PositiveInt, + newLine: PositiveInt, + /** Which copy of an unchanged line the reviewer selected in a split diff. */ + side: PullRequestDiffSide, + }), +]); +export type PullRequestReviewPosition = typeof PullRequestReviewPosition.Type; + /** One remark in a review that has not been sent yet, anchored to a line of the diff. */ export const PullRequestReviewCommentDraft = Schema.Struct({ path: TrimmedNonEmptyString, @@ -865,8 +885,7 @@ export const PullRequestReviewCommentDraft = Schema.Struct({ * the hosts that address a comment by one path ignore this. */ oldPath: Schema.optional(TrimmedNonEmptyString), - line: PositiveInt, - side: PullRequestDiffSide, + position: PullRequestReviewPosition, body: CommentBody, }); export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b5aa4290d005..0cd6dfcc0eaf 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -115,6 +115,9 @@ export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const DEFAULT_SIDEBAR_HIDE_PROVIDER_ICONS = false; export const ClientSettingsSchema = Schema.Struct({ + // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the + // app quits; a quick tap only shows a hint. Browser clients ignore it. + confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmWorktreeRemoval: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), @@ -813,6 +816,7 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + confirmQuit: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmWorktreeRemoval: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 13f9c04298ea..052fedeb526f 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -40,6 +40,15 @@ describe("normalizeGitRemoteUrl", () => { "gitlab.company.com/team/project", ); }); + + it("normalizes SCP-like remotes with non-git SSH users", () => { + expect(normalizeGitRemoteUrl("gitlab@gitlab.example.com:group/project.git")).toBe( + "gitlab.example.com/group/project", + ); + expect(normalizeGitRemoteUrl("deploy@bitbucket.org:workspace/repo.git")).toBe( + "bitbucket.org/workspace/repo", + ); + }); }); describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index a9e220830b9a..3f14e1b78d4a 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -133,7 +133,9 @@ export function normalizeGitRemoteUrl(value: string): string { } } - const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); + const scpStyleHostAndPath = /^[a-zA-Z0-9._-]+@([^:/\s]+):([^/\s]+(?:\/[^/\s]+)+)$/i.exec( + normalized, + ); if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; } diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 458225ebee9f..e4e788e9d24e 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { detectSourceControlProviderFromRemoteUrl, getChangeRequestTerminologyForKind, + isSshRemoteUrl, resolveChangeRequestIndicator, resolveChangeRequestPresentation, resolveThreadChangeRequest, @@ -167,4 +168,71 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { baseUrl: "https://self-hosted.example.test:8443", }); }); + + it("matches self-hosted providers by complete DNS labels", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://github.example.com/owner/repo.git")?.kind, + ).toBe("github"); + expect( + detectSourceControlProviderFromRemoteUrl("https://gitlab.example.com/group/repo.git")?.kind, + ).toBe("gitlab"); + expect( + detectSourceControlProviderFromRemoteUrl("https://bitbucket.example.com/workspace/repo.git") + ?.kind, + ).toBe("bitbucket"); + }); + + it("does not match provider names embedded in unrelated DNS labels", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://notgithub.example.com/owner/repo.git") + ?.kind, + ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl("https://notgitlab.example.com/group/repo.git") + ?.kind, + ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl( + "https://notbitbucket.example.com/workspace/repo.git", + )?.kind, + ).toBe("unknown"); + }); + + it("detects SSH remotes with non-git SSH users (e.g. gitlab@, deploy@)", () => { + expect( + detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git")?.kind, + ).toBe("gitlab"); + expect( + detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git") + ?.baseUrl, + ).toBe("https://gitlab.example.com"); + expect(detectSourceControlProviderFromRemoteUrl("deploy@github.com:owner/repo.git")?.kind).toBe( + "github", + ); + expect( + detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, + ).toBe("bitbucket"); + }); +}); + +describe("isSshRemoteUrl", () => { + it("recognises SCP-like SSH URLs with any SSH user prefix", () => { + expect(isSshRemoteUrl("git@github.com:owner/repo.git")).toBe(true); + expect(isSshRemoteUrl("gitlab@gitlab.example.com:group/project.git")).toBe(true); + expect(isSshRemoteUrl("deploy@bitbucket.org:workspace/repo.git")).toBe(true); + }); + + it("recognises ssh:// URLs with any case", () => { + expect(isSshRemoteUrl("ssh://git@gitlab.example.com/group/project.git")).toBe(true); + expect(isSshRemoteUrl("ssh://git@gitlab.example.com:22/group/project.git")).toBe(true); + expect(isSshRemoteUrl("SSH://git@gitlab.example.com/group/project.git")).toBe(true); + expect(isSshRemoteUrl("SsH://git@gitlab.example.com/group/project.git")).toBe(true); + }); + + it("returns false for HTTPS, local paths, and SCP-like paths without a colon", () => { + expect(isSshRemoteUrl("https://gitlab.example.com/group/project.git")).toBe(false); + expect(isSshRemoteUrl("/home/user/repos/project")).toBe(false); + expect(isSshRemoteUrl("")).toBe(false); + expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false); + }); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 129afb3bf144..7e33e8a2654d 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -187,19 +187,22 @@ export function getChangeRequestTerminologyForKind( }; } +const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/; + +export function isSshRemoteUrl(remoteUrl: string): boolean { + const trimmed = remoteUrl.trim(); + return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://"); +} + function parseRemoteHost(remoteUrl: string): string | null { const trimmed = remoteUrl.trim(); if (trimmed.length === 0) { return null; } - if (trimmed.startsWith("git@")) { - const hostWithPath = trimmed.slice("git@".length); - const separatorIndex = hostWithPath.search(/[:/]/); - if (separatorIndex <= 0) { - return null; - } - return hostWithPath.slice(0, separatorIndex).toLowerCase(); + const scpMatch = SCP_SSH_REMOTE_PATTERN.exec(trimmed); + if (scpMatch?.[1]) { + return scpMatch[1].toLowerCase(); } try { @@ -221,12 +224,16 @@ function toBaseUrl(host: string): string { return `https://${host}`; } +function hasDnsLabel(host: string, label: string): boolean { + return host.split(".").includes(label); +} + function isGitHubHost(host: string): boolean { - return host === "github.com" || host.includes("github"); + return host === "github.com" || hasDnsLabel(host, "github"); } function isGitLabHost(host: string): boolean { - return host === "gitlab.com" || host.includes("gitlab"); + return host === "gitlab.com" || hasDnsLabel(host, "gitlab"); } function isAzureDevOpsHost(host: string): boolean { @@ -242,7 +249,7 @@ function isAzureDevOpsHost(host: string): boolean { } function isBitbucketHost(host: string): boolean { - return host === "bitbucket.org" || host.includes("bitbucket"); + return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } export function detectSourceControlProviderFromRemoteUrl( diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index cecc07c6e670..fb231fbacb20 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics globalDate:off -- A fixed instant keeps calendar-window assertions deterministic. -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { enumerateHourStarts, @@ -54,4 +54,20 @@ describe("hourly usage formatting", () => { expect(window.sinceTime).toBe("2026-08-10T12:37:00.000Z"); expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z"); }); + + it("degrades an unknown resolved zone to UTC instead of crashing", () => { + const resolved = new Intl.DateTimeFormat().resolvedOptions(); + const resolvedOptions = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ ...resolved, timeZone: "Etc/Unknown" }); + + try { + const now = new Date("2026-08-11T12:37:42.123Z"); + + expect(makeWindow(1, now, "hour").timeZone).toBe("UTC"); + expect(makeWindow(30, now).timeZone).toBe("UTC"); + } finally { + resolvedOptions.mockRestore(); + } + }); }); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index ef2b2bcf21a1..bd751829dd87 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -179,13 +179,25 @@ export function makeWindow( now = new Date(), resolution: UsageResolution = "day", ): UsageSummaryInput { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const format = new Intl.DateTimeFormat("en-CA", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); + let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch { + // An unknown zone should degrade to UTC rather than crash the page. + timeZone = "UTC"; + format = new Intl.DateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } const untilDay = format.format(now); if (resolution === "hour") { // Minute-aligned bounds keep labels readable while still representing an diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index c481afc5c224..4f60f35a101d 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -45,6 +45,16 @@ const makeSuccessfulProcess = (stdout: string) => { }); }; +const makeDelayedSuccessfulProcess = (stdout: string, delayMs: number) => { + const process = makeSuccessfulProcess(stdout); + return { + ...process, + exitCode: Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; +}; + const makeRunningProcess = (onKill: () => void) => { let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null; return ChildProcessSpawner.makeHandle({ @@ -98,6 +108,9 @@ describe("ssh tunnel scripts", () => { assert.include(script, "exec npx --yes 't3@latest' \"$@\""); assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\""); assert.include(script, "could not install 't3@latest'"); + assert.include(script, "require_installed_t3_cli npx --yes --package 't3@latest'"); + assert.include(script, "require_installed_t3_cli npm exec --yes --package 't3@latest'"); + assert.include(script, "npm produced no t3 executable"); assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"'); assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); assert.include(script, "remote_node_satisfies_engine()"); @@ -139,6 +152,10 @@ describe("ssh tunnel scripts", () => { assert.include(script, "exec npx --yes 't3@nightly; touch /tmp/t3-owned' \"$@\""); assert.include(script, "exec npm exec --yes 't3@nightly; touch /tmp/t3-owned' -- \"$@\""); + assert.include( + script, + "require_installed_t3_cli npx --yes --package 't3@nightly; touch /tmp/t3-owned'", + ); assert.notInclude(script, "exec npx --yes t3@nightly; touch /tmp/t3-owned"); }); @@ -183,6 +200,9 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); assert.notInclude(buildRemoteLaunchScript(), "server-home"); assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); + assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); + assert.include(buildRemoteLaunchScript(), 'if [ -s "$LOG_FILE" ]; then'); + assert.include(buildRemoteLaunchScript(), "It wrote nothing to %s"); assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( buildRemotePairingScript(target), @@ -244,6 +264,29 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect("allows cold remote launches to exceed the default SSH command timeout", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 75_000)), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); + + return Effect.gen(function* () { + const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(75)); + + const result = yield* Fiber.join(fiber); + assert.equal(result.remotePort, 3774); + }).pipe(Effect.provide(processLayer)); + }); + it("allows the remote port picker to run without a state file path", () => { assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 5890c4b1e478..0052505d4b0b 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -54,7 +54,8 @@ const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; -const REMOTE_READY_TIMEOUT_MS = 15_000; +const REMOTE_READY_TIMEOUT_MS = 60_000; +const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { @@ -425,10 +426,26 @@ fi if command -v t3 >/dev/null 2>&1; then exec t3 "$@" fi +# npm extracts a package before it runs the native builds of its dependencies, +# so a failed build (t3 depends on node-pty, which needs a C toolchain) leaves +# the npx cache without a t3 executable. \`npx --yes\` then exits 0 without +# running anything at all, which the caller only ever sees as a server that +# never becomes ready. Resolve the CLI once up front so that install failure is +# reported here, with npm's own output on stderr. +require_installed_t3_cli() { + T3_CLI_PATH="$("$@" -- sh -c 'command -v t3' || true)" + if [ -n "$T3_CLI_PATH" ]; then + return 0 + fi + printf 'Remote host installed %s but npm produced no t3 executable, which usually means a native dependency (node-pty) failed to build. Install a C toolchain on the remote host (Debian/Ubuntu: build-essential, Fedora/RHEL: gcc-c++ make, macOS: xcode-select --install) and try again.\\n' @@T3_PACKAGE_SPEC@@ >&2 + return 1 +} if command -v npx >/dev/null 2>&1; then + require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 exec npx --yes @@T3_PACKAGE_SPEC@@ "$@" fi if command -v npm >/dev/null 2>&1; then + require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@" fi printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because node/npm/npx are unavailable on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 @@ -580,7 +597,11 @@ if [ -z "$REMOTE_PORT" ]; then printf 'managed\\n' >"$MANAGED_FILE" if ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; then printf 'Remote T3 server did not become ready on 127.0.0.1:%s.\\n' "$REMOTE_PORT" >&2 - tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true + if [ -s "$LOG_FILE" ]; then + tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true + else + printf 'It wrote nothing to %s, so it exited before producing any output.\\n' "$LOG_FILE" >&2 + fi kill "$REMOTE_PID" 2>/dev/null || true wait_for_pid_exit "$REMOTE_PID" rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" @@ -705,6 +726,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), + timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), diff --git a/upstream-pr-body.md b/upstream-pr-body.md new file mode 100644 index 000000000000..89e1c86e0bb2 --- /dev/null +++ b/upstream-pr-body.md @@ -0,0 +1,91 @@ +## The problem + +`refreshThreadShellSummary` loads every activity row a thread has ever produced — payloads included — to compute a single integer, `pendingUserInputCount`. + +Activity payloads are the tool timeline, so the cost of one refresh scales with the thread's entire history. Across the 402 threads on a heavily-used instance, comparing what the refresh reads against what the derivation needs: + +| thread | rows read | of which needed | bytes read | of which needed | +| ------ | --------- | --------------- | ---------- | --------------- | +| median | 277 | 0 | 2.8 MB | 0 | +| p95 | 1,190 | 0 | 60.4 MB | 0 | +| p99 | 4,279 | 0 | 123.0 MB | 0 | +| max | 11,137 | 0 | 493.3 MB | 0 | + +The "needed" column is zero for 397 of 402 threads, and that is not a quirk of this dataset: `pendingUserInputCount` derives only from `user-input.requested`, `user-input.resolved` and `provider.user-input.respond.failed`, which exist only when a provider stops mid-turn to ask the user something. Even on the 5 threads here that _have_ had one, they account for **2–4 rows out of thousands** — 1.3 KB out of 3.5 MB on the largest. + +So the read is not merely oversized on one unusual thread; it is reading the whole timeline to find a handful of rows that are usually not there at all. + +## This is already reported + +- **#5719** — _Assistant streaming causes full-thread projection scans for every text delta_ — names `refreshThreadShellSummary` and this exact reload. +- **#4597** — _Headless nightly server crashes with JavaScript heap out of memory after ~23.5 hours_ — the symptom. That report died at ~23.5 h; on the instance measured here, systemd recorded a 25 h run peaking at **31.8 G** with **28.8 G read from disk**, and shorter runs at 32.4 G and 36.8 G against a raised `--max-old-space-size`. At the cap the process sits in back-to-back full GCs and every connected client stalls until it is restarted. +- On #5719, `dain` also observed cross-thread head-of-line blocking: one busy thread delaying unrelated turns for minutes. That matches what this looks like from a client — everything slow, not one screen. + +## How this relates to #5855 and #6608 + +Both of those reduce **how often** the refresh runs. This PR reduces **what it costs when it runs**. They compose; none of them replaces another: + +| | #5855 | #6608 | this PR | +| ----------------------------------------- | ----- | ----- | ------- | +| Skip refresh for assistant deltas | ✅ | ✅ | — | +| Skip refresh for streaming activity kinds | — | ✅ | — | +| Make the remaining refreshes cheap | — | — | ✅ | + +Neither #5855 nor #6608 changes the read itself — both still call `projectionThreadActivityRepository.listByThreadId({ threadId })` and pull the full payload set. With either merged, every _lifecycle_ event (`user-input.requested/resolved`, approval events, proposed-plan upserts, `thread.session-set`, `thread.turn-diff-completed`) still pays the whole-thread read. On the instance above that is up to 467 MB per event, for one integer. + +Merging this alongside them means the refreshes that remain are also cheap. If the maintainers prefer #6608's shape, this still applies unchanged on top of it — the two touch different lines. + +## The change + +`derivePendingUserInputCountFromActivities` only reacts to three kinds — `user-input.requested`, `user-input.resolved` and `provider.user-input.respond.failed` — and `continue`s past everything else. The read now filters on exactly those three, with the kinds declared next to the deriver so the two stay in step. + +Measured against the same database. The busiest thread, warm, before schema decode and the sort that follows: + +``` +before: 10,652 rows 467.4 MB 348.8 ms +after : 0 rows 0.000 MB 6.9 ms +``` + +And on the threads where the filtered read is _not_ empty — the fix's worst case: + +``` +3,778 rows / 3.5 MB → 2 rows / 1.3 KB 7.3 ms → 1.9 ms +1,576 rows / 1.8 MB → 3 rows / 1.3 KB 2.3 ms → 0.8 ms +1,226 rows / 1.6 MB → 2 rows / 1.4 KB 1.9 ms → 0.6 ms +``` + +Deployed, the traced projection step moved: + +``` +applyThreadsProjection p50 1,707 ms → 1.5 ms p95 4,814 ms → 17.4 ms max 15,010 ms → 35.1 ms +``` + +94% of orchestration events there reach this path (`thread.activity-appended` alone), averaging ~356/hour and peaking at 2,613 in one hour. + +`ProjectionThreadActivityRepository` gains `listByThreadIdAndKinds`, which reuses the row decoding of `listByThreadId`, keeps its exact ordering, and short-circuits an empty kind list without issuing a query. `listByThreadId` is unchanged and still used everywhere else. + +No behaviour change: the rows removed from the read are ones the deriver already skipped. + +## Why it went unnoticed for so long + +The code landed in #1973 (2026-04-13) and has not been touched in the 1,239 commits since. It only bites at the tail — on the instance measured, the median thread holds 306 activity rows (~2.8 MB), which is unnoticeable; the p99 holds 4,279 rows / 123 MB. It needs long-lived threads _and_ a server process that stays up for days, which is not the common desktop profile. + +Adjacent fixes have repeatedly addressed the same underlying volume on the **serve** path — #4622 pruning activity payloads over the wire, #4788 gzipping snapshots, #5482 dropping MCP tool results from thread payloads, #5147 bounding catch-up replay. This is the same volume problem on the **projection** path. + +## Tests + +Five repository tests cover kind filtering, ordering parity with the unfiltered list, payload-decoding parity, thread isolation, and the empty-kinds short circuit. + +One `ProjectionPipeline` test is added, because the existing suite could not catch the failure mode this change introduces. The suite asserted `pendingUserInputCount` only where it settles back to `0`, so a filter that dropped every row would still have passed. The new test appends a `user-input.requested` activity alongside unrelated tool noise and asserts the count is **1**. + +Verified by mutation: renaming the filtered kinds makes only the new test fail — the other 22 in that file still pass. + +Equivalence was also checked against real data. Replaying the deriver over all 402 threads on the instance above, once from the full activity list and once from the filtered list, produced identical counts for every thread. + +Not a UI change, so no screenshots. + +--- + +Written by Claude Opus 5 in Claude Code. + +Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>