diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index a29ad027f..5ee479362 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -63,6 +63,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Remote media fallback, recording transfer, browser context menus, MCP snapshot output and macOS installer art / `6c583620ff7ad3235b135af7107c0543467eecfa` | `a01b227d6f37d1cfed7a2f47aaace2f72ea76ce3` (#10619), `9e37f0c291974d084a59d7b9f165a1cb056e6043` (#10572), `b5f7fa0ede2a0d791226e6474c9cc4374dd89cc9` (#10670), `061543e9e5b54ec0048725c37d52fef2962df173` (#10501), `991526383f95eefdd66ffd2853136c6dd34ae008` (#10632), `5d14c0e9686d8bedf47dd93dd3811fd639255c7e` (#10819), `0fe4c99ee6df4cbb7a9064d2d86ece65ecef5eb3` (#10820) | All seven adopted. Desktop retries missing drive or POSIX absolute media from remote threads (relative images arrive workspace-resolved) against its primary environment; UNC and device paths never fall back, client or server. Agent recordings upload once to a remote requesting environment, while the desktop's own primary environment gets the saved path with no copy (older desktops get an update-required error). Pylon adaptations: transferred recordings and `save: true` screenshots live in `browser-artifacts//` so rollbacks keep them and thread deletion removes them; `preview_snapshot` keeps upstream's read-only/idempotent hints because saves stay in that per-thread store; Codex `tool_timeout_sec` and OpenCode `timeout` are 180 s for Pylon's MCP server, Claude and ACP providers keep their defaults. #10501 uses `Schema.TaggedErrorClass` for Pylon's Effect. DMG layout, sizes and art follow upstream (Nightly aurora raster has no marks); both variants carry the Pylon mark and "Drag Pylon into Applications." instead of T3's wordmark; titles, artifact names and signing unchanged. Split from window capture #8103, which lands separately. Cursor unchanged. | [Desktop fixes #462](https://github.com/pylon-code/pylon/pull/462); shared 6, client-runtime 50, server MCP/asset/config/pipeline/adapter 308 + router 5, web 79, desktop 126 and build-script 80 tests; contracts, shared, client-runtime, t3, web, desktop, mobile and scripts typechecks; scoped lint/format. | | Files in question answers / `6c583620ff7ad3235b135af7107c0543467eecfa` | `7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49` (#9871) | Adopted across contracts, server, web, desktop and mobile: `attachmentsByQuestionId` on respond commands and events, the `questionAttachments` capability, normalizer claims, `user-input.answer-submitted` history, provider answer path lines in `ProviderService`, and a separate `projection.attachment-cleanup` cursor that retains answer files through reverts and deletes. Per adapter: Codex, Claude, Cursor, Grok and OpenCode receive path lines; Antigravity questions never accept custom answers, so the decider refuses their attachments; Prime asks no user-input questions. Pylon adaptation: normalizer and failed-dispatch cleanup keep `thread.input-queue.follow-up`; mobile readiness uses the #10404 upload helpers from #460 as upstream does. Pylon diverges on cleanup mechanics, which upstream shares at the frozen head: the live path writes the cleanup cursor with the projector cursors at the last finished cleanup; bootstrap selects only revert/delete rows past it without decoding payloads, lists the attachments directory once and skips threads without files (1,000 deleted threads with 3,000 files: 28.8 s to 2 ms); a database without the row starts at its lowest projector cursor; file errors are logged and retried once on the next start instead of pinning the cursor; revert retention reads only answer activities; unused `minLastAppliedSequence` removed. Mobile discards question drafts only on live thread data and explains a paste refused by an older server. Claude, Cursor, Grok and OpenCode are code-path verified; upstream live-tested Codex. Cursor unchanged. | [Question attachments #463](https://github.com/pylon-code/pylon/pull/463); 868 focused tests (28 files: server 16, web 7, mobile 5), five package typechecks, scoped lint/format, before/after bootstrap probe. | | Desktop window capture (SnapShots), Electron 44 and post-Electron 43 recording / `6c583620ff7ad3235b135af7107c0543467eecfa` | `299404a754f52c02c69634528d8856b3c93b378c` (#8103), `9fe4d6568b5e4852f6085d3a22dbc5aa3a632408` (#10645), `ef7014d851f56bb037a9da963095ffd883c7fa08` (#9001), `4a9d2d0ced2a2b899dbee9e4a5162fd83f81edb8` (#8626, partial); skipped `8de9169f078fdafedf47d8c299cb8e2c79fda6fb` (v0.0.40 release bump) | Adopted opt-in capture on macOS, Windows and Wayland (KDE/Hyprland helpers, GNOME extension, Niri, portal), setup/settings, attachment metadata and fenced prompt context, plus CI/release steps for both crates. Electron 41.5.0 moves to 44.1.0 (maintainer approved): packaged macOS builds require 13.0 and mac update manifests carry `minimumSystemVersion` 22.0.0 so macOS 12 keeps its build; the runtime repair script uses Electron's checksummed installer, checks its fallback download against the package's bundled checksums and reinstalls partial runtimes, and desktop tests install the runtime once in a Vitest global setup; pickers without a default path reopen in the last picked directory; #8626 is superseded except its required `websql` removal. #9001 restores preview recording via `getDisplayMedia` arming and keeps inactive macOS guests paintable, preserving Pylon's hardware-first bitrate recorder, #10403 `ideal` frame rate, stream cleanup, lock timeouts and z-index presentation; #462's recording-transfer tests now use its capture trigger. Agent screenshots and snapshots keep `capturePage` without `stayHidden` (unchanged 41.5.0 to 44.1.0) under a surface lease that composites the guest inside the window. Maintainer-approved rename to per-channel `com.pylon.code[.nightly|.dev].desktop` (XDG portals need a dotted app ID) with matching WM class, AppImage `StartupWMClass` and entry icon; the legacy `pylon-code-url-handler.desktop` is removed once the scheme default moves; AGENTS.md updated. Pylon-owned coexistence identifiers: `snap-shot@pylon-code.com` / `PylonSnapShot` extension, channel-keyed `pylon-kde-snap-shot`/`pylon-hyprland-snap-shot` installs and Pylon Niri endpoint; Pylon copy. Captured-window context also rides Pylon follow-ups; undecodable capture metadata drops only the source; `accessibleText` is dropped when structured accessibility exists (icon/tree sidecar deferred until payload size is measured as a problem; the double `SnapShotSource` decode in `ForwardCompatibleOptional` is accepted until capture-heavy threads show decode cost); over-quota drafts no longer re-deliver captures and undeliverable captures are reported once; accessibility details format only when opened; Settings -> Keybindings warns on the global capture chord. Cursor unchanged. | [Window capture #461](https://github.com/pylon-code/pylon/pull/461); after rebasing on `origin/pylon` (through #477): desktop 629 (23 Linux-only skipped), web 584, contracts 164, server 131 (`ProviderService` and question-attachment suites), build-script/manifest/native TS 96 tests; seven Electron-loading desktop suites pass from a fresh or partial `dist`; cargo fmt/test on Linux (resource-monitor 18, KDE 11, Hyprland 10); contracts, shared, client-runtime, t3, web, desktop and mobile typechecks; scoped lint/format. | +| Pre-cursor gap sources before `beae2147a9`: previously unrecorded / `6c583620ff7ad3235b135af7107c0543467eecfa` | `8dcb96314c976899e4df6951fb9af03131c2a46f` (#8733), `8b817cbcaad71a53e2ef73f3881067f8aa8094bc` (#8840), `7963ac7404ff2196c3e8e4198ecc02a5e742b0a1` (v0.0.37), `ad38700ac678b8c8a0310d434a44d94a7ee6a47f` (#8917), `4e8e64fc065a4a72535eee5fe60b689f5b48d35c` (#8933), `5ce92c2f192040bf77c0211fa33bf03c74c031ef` (#8932), `f47e74004af232f0e3df8dc10093601d1c2c3ea3` (#8851), `0df043fd4eaa190eb491a3060836156eb0ae915e`, `85b656ff300f71060ad6305c7e1e29a72b442ce9`, `c78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc` (#8085), `ce71c04f0aa9d2e5cd340e2a04cb1b0d5e24419d` (#8936), `0947c30e6946b2ad6d6cd518fd44292e75e834e8` (#9010), `b883fc066ea5c9bebbe1c3e9b4bc2471aab3685f` (#8367), `9dbdcece5f488c66f6b9ac516b610f45bbbb676a` (#9033), `9d1879b142a2f5d01383357646a4679d1a2bd202` (#9076), `cb007469161ff0db2bc2dc8123c4b30e186aae50` (#8925), `692eb1a5792b9930959b19805acf2bf2611318c9` (#9092), `60cef47ec983637ddc68faed7b1488b6f3c3a175` (v0.0.38) | Adopted: #8085 names remote web session cookies from the persisted environment ID, migrates a valid legacy `t3_session` and gives CLI auth a launcher-free identity layer; #8367 bootstraps from one config subscription, with Pylon's usage-limit source and `/usage-limits` options on web and mobile; #9076 adds Direct, Hold and Double press quit modes, migrating booleans (true to Hold, false to Direct) in browser storage and the desktop file while `readConfirmQuit` keeps its Hold fallback; #8925 adds Project settings to web thread menus; #8851 keeps Pylon's left-run branch layout. Partial: #8932 shimmer clipping only, its shimmer derivation superseded by #10173 and #10273; #8936 web tool grouping now shared with client-runtime, its viewed-image assets already landed with the #9023 port. Already covered: #9010; the #8917 conventions rule (via #9321), with its approvability file skipped. Superseded: #8733 by #8734 (#221), #8840 by #9606, #9033 by #8890 (#402), #9092 by #9332. Skipped: both release bumps and the three CodeRabbit configs; neither CodeRabbit nor Macroscope runs on Pylon PRs. Cursor unchanged. | [Pre-cursor gaps #481](https://github.com/pylon-code/pylon/pull/481); focused tests (server 88, client-runtime 213, web 460, contracts 112, desktop 43), six package typechecks, scoped lint and format. | ## Deferred register diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 06884c4ce..d55673151 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -174,9 +174,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, onQuitShortcut: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { - if (state !== "down" && state !== "up") return; - listener(state); + const wrappedListener = (_event: Electron.IpcRendererEvent, hint: unknown) => { + if (typeof hint !== "object" || hint === null || !("state" in hint)) return; + if (hint.state === "up") { + listener({ state: "up" }); + return; + } + if ( + hint.state === "down" && + "mode" in hint && + (hint.mode === "hold" || hint.mode === "double-click") + ) { + listener({ state: "down", mode: hint.mode }); + } }; ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 10f0331b7..2b16c7ef6 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -27,7 +27,7 @@ const clientSettings: ClientSettings = { browserAutoShowFloatingPreview: false, browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], browserDefaultProfileId: "work", - confirmQuit: true, + confirmQuit: "double-click", confirmThreadArchive: true, confirmThreadDelete: false, confirmThreadUnpin: false, @@ -344,22 +344,24 @@ describe("DesktopClientSettings", () => { yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString( environment.clientSettingsPath, - '{"confirmQuit":"hold","timestampFormat":"12-hour"}', + '{"confirmQuit":false,"diffLayout":"unified","timestampFormat":"12-hour"}', ); const saved = Option.getOrThrow(yield* settings.get); - assert.isTrue(saved.confirmQuit); + // A legacy boolean is readable: it migrates to its mode and saves canonically. + assert.strictEqual(saved.confirmQuit, "direct"); yield* settings.set({ ...saved, onboardingCompletedAt: "2026-09-10T12:00:00.000Z" }); assert.deepInclude(yield* readDocument, { - confirmQuit: "hold", + confirmQuit: "direct", + diffLayout: "unified", timestampFormat: "12-hour", onboardingCompletedAt: "2026-09-10T12:00:00.000Z", }); - yield* settings.set({ ...saved, confirmQuit: false }); - assert.deepInclude(yield* readDocument, { confirmQuit: false }); - yield* settings.set({ ...saved, confirmQuit: false, wordWrap: false }); - assert.deepInclude(yield* readDocument, { confirmQuit: false, wordWrap: false }); + yield* settings.set({ ...saved, diffLayout: "split" }); + assert.deepInclude(yield* readDocument, { diffLayout: "split" }); + yield* settings.set({ ...saved, diffLayout: "split", wordWrap: false }); + assert.deepInclude(yield* readDocument, { diffLayout: "split", wordWrap: false }); }), ), ); @@ -376,13 +378,18 @@ describe("DesktopClientSettings", () => { ), set: () => Effect.void, }); - const disabled = DesktopClientSettings.DesktopClientSettings.of({ - get: Effect.succeed(Option.some({ ...clientSettings, confirmQuit: false })), + const direct = DesktopClientSettings.DesktopClientSettings.of({ + get: Effect.succeed(Option.some({ ...clientSettings, confirmQuit: "direct" })), + set: () => Effect.void, + }); + const missing = DesktopClientSettings.DesktopClientSettings.of({ + get: Effect.succeed(Option.none()), set: () => Effect.void, }); - assert.isTrue(yield* DesktopClientSettings.readConfirmQuit(failing)); - assert.isFalse(yield* DesktopClientSettings.readConfirmQuit(disabled)); + assert.strictEqual(yield* DesktopClientSettings.readConfirmQuit(failing), "hold"); + assert.strictEqual(yield* DesktopClientSettings.readConfirmQuit(missing), "hold"); + assert.strictEqual(yield* DesktopClientSettings.readConfirmQuit(direct), "direct"); }), ); }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index f8085edbd..d97205582 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -4,6 +4,7 @@ import { encodeStoredClientSettings, retainUnreadClientSettings, type ClientSettings, + type QuitConfirmationMode, type StoredClientSettings, } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; @@ -134,10 +135,10 @@ const readClientSettings = ( ), ); -/** Reads the hold-to-quit preference, keeping the default hold when settings cannot be read. */ +/** Reads the quit shortcut mode, keeping the default hold when settings cannot be read. */ export const readConfirmQuit = ( clientSettings: DesktopClientSettings["Service"], -): Effect.Effect => +): Effect.Effect => clientSettings.get.pipe( Effect.map( Option.match({ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 5dbaf32f5..2fb7ff9eb 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -28,7 +28,7 @@ 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"; +import { makeQuitShortcutHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -607,15 +607,14 @@ 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({ + // Intercept the quit accelerator before the native menu sees it and apply + // the configured direct, hold, or double-press behavior. + const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, - isEnabled: () => runPromise(DesktopClientSettings.readConfirmQuit(clientSettings)), - notify: (state) => { + getMode: () => runPromise(DesktopClientSettings.readConfirmQuit(clientSettings)), + notify: (hint) => { if (!window.isDestroyed()) { - window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); + window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); } }, // Keep the transparent window focused until the physical shortcut is @@ -626,7 +625,7 @@ export const make = Effect.gen(function* () { }, }); window.webContents.on("before-input-event", (event, input) => { - quitHoldHandler(event, input); + quitShortcutHandler(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") { diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index 84dbf76f0..58809d8eb 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -1,12 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { - makeQuitHoldHandler, - QUIT_DOUBLE_TAP_MS, + makeQuitShortcutHandler, + QUIT_DOUBLE_PRESS_MS, QUIT_HOLD_DURATION_MS, QUIT_HOLD_RELEASE_GRACE_MS, } from "./QuitHold.ts"; -import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; +import type { QuitHoldKeyInput } from "./QuitHold.ts"; +import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; + +const HOLD_DOWN = { state: "down", mode: "hold" } as const; +const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const; +const UP = { state: "up" } as const; function makeInput(overrides: Partial): QuitHoldKeyInput { return { @@ -22,24 +27,24 @@ function makeInput(overrides: Partial): QuitHoldKeyInput { } function makeHarness(options?: { - enabled?: boolean; + mode?: QuitConfirmationMode; platform?: NodeJS.Platform; - isEnabled?: () => Promise; + getMode?: () => Promise; }) { - const notifications: Array = []; + const notifications: Array = []; const concealWindow = vi.fn(); const quit = vi.fn(); - const handler = makeQuitHoldHandler({ + const handler = makeQuitShortcutHandler({ platform: options?.platform ?? "darwin", - isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), - notify: (state) => notifications.push(state), + getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")), + notify: (event) => notifications.push(event), concealWindow, quit, }); const preventDefault = vi.fn(); const send = async (input: QuitHoldKeyInput) => { handler({ preventDefault }, input); - // Let the isEnabled promise settle. + // Let the getMode promise settle. await Promise.resolve(); await Promise.resolve(); }; @@ -57,7 +62,7 @@ function makeHarness(options?: { return { notifications, concealWindow, quit, preventDefault, send, holdFor }; } -describe("makeQuitHoldHandler", () => { +describe("makeQuitShortcutHandler", () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -71,12 +76,12 @@ describe("makeQuitHoldHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); expect(harness.preventDefault).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down"]); + expect(harness.notifications).toEqual([HOLD_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"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("conceals a completed hold, then quits after release", async () => { @@ -89,7 +94,7 @@ describe("makeQuitHoldHandler", () => { expect(harness.quit).not.toHaveBeenCalled(); vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("keeps a concealed hold committed when another key is pressed", async () => { @@ -129,7 +134,7 @@ describe("makeQuitHoldHandler", () => { vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("waits for slow repeats to stop before quitting", async () => { @@ -186,7 +191,7 @@ describe("makeQuitHoldHandler", () => { await harness.send(makeInput({})); await harness.holdFor(500); await harness.send(makeInput({ type: "keyUp" })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); expect(harness.concealWindow).not.toHaveBeenCalled(); expect(harness.quit).not.toHaveBeenCalled(); @@ -196,112 +201,208 @@ describe("makeQuitHoldHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); expect(harness.quit).not.toHaveBeenCalled(); }); - it("quits without showing a hint when hold-to-quit is disabled", async () => { - const harness = makeHarness({ enabled: false }); + it("quits without showing a hint in direct mode", async () => { + const harness = makeHarness({ mode: "direct" }); await harness.send(makeInput({})); expect(harness.concealWindow).not.toHaveBeenCalled(); expect(harness.quit).toHaveBeenCalledTimes(1); expect(harness.notifications).toEqual([]); }); - it("discards a stale isEnabled resolution from a superseded press", async () => { - // Press #1's isEnabled is still pending when the user releases and + it("honors direct mode when the key is released before its mode read settles", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolveMode?.("direct"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }); + + it("does not arm hold mode after a released key's mode read settles", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolveMode?.("hold"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it.each(["direct", "hold", "double-click"] as const)( + "quits on a quick second press without waiting for a pending %s mode read", + async (mode) => { + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; + const harness = makeHarness({ + getMode: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + + await harness.send(makeInput({ type: "keyUp" })); + + resolvers[0]?.(mode); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }, + ); + + it("discards a stale mode resolution from a superseded press", async () => { + // Press #1's mode 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 resolvers: Array<(mode: QuitConfirmationMode) => void> = []; const harness = makeHarness({ - isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), + getMode: () => 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); + // Outside the double-press window, so the second press starts a new hold. + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_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); + // Press #1 resolves late with "direct". It must not quit press #2. + resolvers[0]?.("direct"); await Promise.resolve(); await Promise.resolve(); expect(harness.quit).not.toHaveBeenCalled(); - // Press #2 resolves enabled and completes a full hold. - resolvers[1]?.(true); + // Press #2 resolves to hold and completes the gesture. + resolvers[1]?.("hold"); await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); await harness.send(makeInput({ type: "keyUp" })); expect(harness.quit).toHaveBeenCalledTimes(1); }); - it("quits on a quick double tap, even when the first release was never seen", async () => { - const harness = makeHarness(); + it("quits on a quick double press in double-click mode when the first release is unseen", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); expect(harness.concealWindow).not.toHaveBeenCalled(); expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); }); - it("treats two slow taps as separate presses", async () => { - const harness = makeHarness(); + it("keeps the double-press hint visible after key release until the window ends", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + vi.advanceTimersByTime(100); + await harness.send(makeInput({ type: "keyUp" })); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 101); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + vi.advanceTimersByTime(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("accepts a second full shortcut after the modifier is released and pressed again", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + vi.advanceTimersByTime(100); + + await harness.send(makeInput({ key: "Meta" })); await harness.send(makeInput({})); - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); }); - it("cancels the hold when another key interrupts it", async () => { - const harness = makeHarness(); + it("expires a delayed double-press hint from keydown rather than mode resolution", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); 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(); + vi.advanceTimersByTime(100); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(100); + resolveMode?.("double-click"); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 201); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + vi.advanceTimersByTime(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); }); - it("does not count an interrupted press toward a double tap", async () => { - const harness = makeHarness(); + it("treats two slow presses as separate attempts in double-click mode", async () => { + const harness = makeHarness({ mode: "double-click" }); 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({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); - it("ignores other shortcuts", async () => { + it("quits on a quick second press in hold mode", 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([]); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); - 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 }); - await harness.send(makeInput({ type: "keyUp", meta: false, control: true })); + it("quits on a quick second press in hold mode when the first release is unseen", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + + expect(harness.concealWindow).not.toHaveBeenCalled(); expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); + it("does not count auto-repeat as a second press", async () => { const harness = makeHarness(); await harness.send(makeInput({})); - await harness.holdFor(QUIT_DOUBLE_TAP_MS - 100); + await harness.holdFor(QUIT_DOUBLE_PRESS_MS - 100); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down"]); + expect(harness.notifications).toEqual([HOLD_DOWN]); }); it("does not count a released tap after another shortcut interrupts it", async () => { @@ -313,20 +414,46 @@ describe("makeQuitHoldHandler", () => { await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); }); - it("accepts a second full shortcut after the modifier is released and pressed again", async () => { + it("cancels the hold when another key interrupts it", async () => { const harness = makeHarness(); await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - vi.advanceTimersByTime(100); + await harness.holdFor(500); + // Shift pressed mid-hold breaks the gesture... + await harness.send(makeInput({ shift: true })); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); + // ...so later repeats past the threshold must not quit. + await harness.holdFor(QUIT_HOLD_DURATION_MS); + expect(harness.quit).not.toHaveBeenCalled(); + }); - await harness.send(makeInput({ key: "Meta" })); + it("does not count an interrupted press toward a double press", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + await harness.send(makeInput({ shift: true })); + // A fresh press right after the interruption starts a new attempt. await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_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 }); + await harness.send(makeInput({ type: "keyUp", meta: false, control: true })); expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down", "up"]); }); }); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index dc1516e61..a995184dd 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -1,28 +1,24 @@ // @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 after the shortcut has been held for QUIT_HOLD_DURATION_MS -// and released. -// 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. +import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; + +// The quit accelerator is intercepted in before-input-event, which runs +// before the native menu accelerator. Quitting from the application menu is +// untouched and always 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; +export const QUIT_DOUBLE_PRESS_MS = 500; // "Still held" is proven by auto-repeat keydowns, not by the absence of a // release: macOS suppresses a letter keyUp while the command key is down, so a // tap release can go completely unseen and a release-based timer would quit // anyway. Once held, quitting waits for Q keyUp or a quiet grace period after // repeats stop so they cannot reach the next app. Keyboards with -// auto-repeat disabled use two quick presses or the application menu Quit action. +// auto-repeat disabled must use a double press or the application menu Quit action. +// Supporting holds without repeats requires a native physical key-state check. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. const QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER = 2; -export type QuitHoldState = "down" | "up"; - export interface QuitHoldKeyInput { readonly type: string; readonly key: string; @@ -33,30 +29,32 @@ export interface QuitHoldKeyInput { readonly isAutoRepeat: boolean; } -export interface QuitHoldOptions { +export interface QuitShortcutOptions { readonly platform: NodeJS.Platform; - readonly isEnabled: () => Promise; - readonly notify: (state: QuitHoldState) => void; + readonly getMode: () => Promise; + readonly notify: (event: QuitShortcutHintEvent) => void; readonly concealWindow: () => void; readonly quit: () => void; } -export function makeQuitHoldHandler( - options: QuitHoldOptions, +export function makeQuitShortcutHandler( + options: QuitShortcutOptions, ): (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 complete the hold when armed. + let mode: QuitConfirmationMode | undefined; + let notified = false; + // Set once getMode resolves to hold; auto-repeats may only complete the hold when armed. let armed = false; let quitOnRelease = false; let heldSince = 0; let lastPressAt = 0; let lastRepeatAt = 0; let repeatCadenceMs = 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. + // Incremented when a press is superseded or explicitly cancelled. A plain + // key release does not invalidate its pending mode read: a direct-mode + // press must still quit after that read settles. let generation = 0; const clearWatchdog = () => { @@ -66,21 +64,26 @@ export function makeQuitHoldHandler( } }; - const release = () => { - if (!holding) return; - const shouldNotify = armed || quitOnRelease; - generation += 1; + const release = (cancelPendingMode = true, keepDoublePressHint = false) => { + if (cancelPendingMode) generation += 1; + if (!holding && !notified) return; + const keepHint = keepDoublePressHint && mode === "double-click" && notified; holding = false; armed = false; quitOnRelease = false; lastRepeatAt = 0; repeatCadenceMs = 0; + if (keepHint) return; + + mode = undefined; clearWatchdog(); - if (shouldNotify) options.notify("up"); + if (notified) { + notified = false; + options.notify({ state: "up" }); + } }; - // Dismisses any overlay first: if the quit is cancelled downstream the - // renderer must not be left with a stuck "Hold to Quit" hint. + // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. const quitNow = () => { release(); lastPressAt = 0; @@ -101,11 +104,11 @@ export function makeQuitHoldHandler( if (input.type === "keyUp") { if (key === "q") { const shouldQuit = quitOnRelease; - release(); + release(false, true); if (shouldQuit) options.quit(); } else if (key === modifierKey) { if (!quitOnRelease) { - release(); + release(false, true); } else { quitAfterQuietPeriod(); } @@ -133,9 +136,13 @@ export function makeQuitHoldHandler( } if (!modifierDown || input.alt || input.shift || key !== "q") { - // Re-pressing the platform modifier starts a second full shortcut. + // Re-pressing the platform modifier is the first half of a second full + // quit shortcut, so it must not cancel an active double-press window. if (key === modifierKey && !input.alt && !input.shift) return; - // Other keys cancel the first tap even if its release already arrived. + + // Other keys cancel the hold and the first tap, even after release. + // Keep this separate from release(), which also runs when a fresh Q + // keydown follows a keyUp that macOS did not deliver. if (!input.isAutoRepeat) { lastPressAt = 0; release(); @@ -146,7 +153,7 @@ export function makeQuitHoldHandler( event.preventDefault(); if (input.isAutoRepeat) { - if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { + if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { armed = false; quitOnRelease = true; options.concealWindow(); @@ -158,28 +165,49 @@ export function makeQuitHoldHandler( 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) { + // A fresh keydown supersedes the current physical hold or the hint kept + // alive after a detected release. + if (holding || notified) release(); + + generation += 1; + // Every mode accepts two presses. Quit before reading settings so a slow + // read cannot delay the second press. Repeats never reach this branch. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { quitNow(); return; } - if (holding) release(); - generation += 1; const pressGeneration = generation; holding = true; heldSince = now; - void options.isEnabled().then( - (enabled) => { + void options.getMode().then( + (resolvedMode) => { if (generation !== pressGeneration) return; - if (!enabled) { - // Hold-to-quit disabled: a single press quits immediately. + if (resolvedMode === "direct") { quitNow(); return; } + if (resolvedMode === "double-click") { + const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now); + if (remainingMs <= 0) { + release(); + return; + } + mode = resolvedMode; + notified = true; + options.notify({ state: "down", mode: resolvedMode }); + watchdog = setTimeout(release, remainingMs); + return; + } + + // A hold cannot be armed after its physical press has ended. + if (!holding) return; + + mode = resolvedMode; + notified = true; + options.notify({ state: "down", mode: resolvedMode }); + armed = true; - options.notify("down"); // 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(() => { diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index c5aebcd87..ce478962b 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -31,7 +31,9 @@ type ConnectionLayerSource = | typeof mobileBackgroundActivityReporterLayer; const providedClientConnectionLayer = snapshotLoaderLayer.pipe( - Layer.provideMerge(Connection.layer), + Layer.provideMerge( + Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }), + ), Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index ec4c3deaf..9750e8428 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -268,7 +268,7 @@ export function ShimmeringWorkContent(props: { return ( setAvailableWidth(event.nativeEvent.layout.width)} > diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 01bac08db..028fe53e0 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -34,6 +35,7 @@ const makeEnvironmentAuthLayer = (overrides?: Partial { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("prefers a bearer token over a stale legacy cookie", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const bearer = yield* serverAuth.issueSession(); + const verified = yield* serverAuth.authenticateHttpRequest({ + cookies: { [sessions.legacyCookieName ?? "t3_session"]: "stale" }, + headers: { authorization: `Bearer ${bearer.token}` }, + } as never); + + expect(verified.sessionId).toBe(bearer.sessionId); + }).pipe(Effect.provide(makeEnvironmentAuthLayer({ mode: "web", host: "192.168.1.50" }))), + ); + it.effect("does not exchange ordinary pairing grants for administrative access tokens", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index b33e6c2e7..be7f0eed4 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -30,6 +30,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -562,6 +563,34 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | return token.length > 0 ? token : null; } +export function selectRequestCredential( + request: HttpServerRequest.HttpServerRequest, + cookieName: string, + legacyCookieName: string | undefined, +) { + const cookieToken = request.cookies[cookieName]; + if (cookieToken !== undefined) { + return { token: cookieToken, source: "cookie" } as const; + } + + const bearerToken = parseBearerToken(request); + if (bearerToken !== null) { + return { token: bearerToken, source: "bearer" } as const; + } + + const dpopToken = parseDpopToken(request); + if (dpopToken !== null) { + return { token: dpopToken, source: "dpop" } as const; + } + + const legacyToken = legacyCookieName ? request.cookies[legacyCookieName] : undefined; + if (legacyToken !== undefined) { + return { token: legacyToken, source: "legacy-cookie" } as const; + } + + return undefined; +} + export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -600,17 +629,19 @@ export const make = Effect.gen(function* () { const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, ): Effect.Effect => { - const cookieToken = request.cookies[sessions.cookieName]; - const bearerToken = parseBearerToken(request); - const dpopToken = parseDpopToken(request); - const credential = cookieToken ?? bearerToken ?? dpopToken; - if (!credential) { + const credential = selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } - return authenticateToken(credential).pipe( + const dpopToken = parseDpopToken(request); + return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { - if (!dpopToken || dpopToken !== credential) { + if (!dpopToken || dpopToken !== credential.token) { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound access token requires DPoP authorization.", @@ -1006,4 +1037,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe( export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); -export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer)); +export const runtimeLayer = layer.pipe( + Layer.provideMerge(storageLayer), + Layer.provideMerge(ServerEnvironment.identityLayer), +); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 4859b57cc..d8a0e3048 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -35,6 +36,7 @@ const makeEnvironmentAuthLayer = ( EnvironmentAuth.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(makeServerConfigLayer(overrides)), ); diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 8e4c21710..982ff397d 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -4,12 +4,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; const makeEnvironmentAuthPolicyLayer = ( overrides?: Partial, ) => EnvironmentAuthPolicy.layer.pipe( + Layer.provide(ServerEnvironment.identityLayer), Layer.provide( Layer.effect( ServerConfig.ServerConfig, @@ -107,7 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("remote-reachable"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ @@ -143,7 +145,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { const descriptor = yield* policy.getDescriptor(); expect(descriptor.policy).toBe("remote-reachable"); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 9945c6906..446b8a8bb 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; export class EnvironmentAuthPolicy extends Context.Service< @@ -15,6 +16,7 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const isRemoteReachable = isRemoteReachableHost(config.host); const policy = @@ -42,6 +44,7 @@ export const make = Effect.gen(function* () { port: config.port, host: config.host, instanceKey: config.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: config.devUrl !== undefined, }), }; diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1453caaf3..1e2d5c60e 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -10,15 +11,14 @@ import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -const makeServerConfigLayer = ( - overrides?: Partial>, -) => +const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( ServerConfig.ServerConfig, Effect.gen(function* () { @@ -30,12 +30,19 @@ const makeServerConfigLayer = ( }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); +const makeServerEnvironmentLayer = (environmentId: EnvironmentId) => + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(environmentId), + }); + const makeSessionStoreLayer = ( - overrides?: Partial>, + overrides?: Partial, + environmentId = EnvironmentId.make("test-environment"), ) => SessionStore.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide(makeServerEnvironmentLayer(environmentId)), Layer.provide(makeServerConfigLayer(overrides)), ); @@ -70,10 +77,32 @@ const failingSessionLookupCredentialLayer = Layer.effect( Layer.provide(failingSessionLookupRepositoryLayer), Layer.provide(ServerSecretStore.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))), Layer.provide(makeServerConfigLayer()), ); it.layer(NodeServices.layer)("SessionStore.layer", (it) => { + it.effect("keys remote cookies by environment identity instead of state directory", () => + Effect.gen(function* () { + const cookieName = (stateDir: string, environmentId: EnvironmentId) => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + return sessions.cookieName; + }).pipe( + Effect.provide( + makeSessionStoreLayer({ mode: "web", host: "192.168.1.50", stateDir }, environmentId), + ), + ); + + const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one")); + const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one")); + const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two")); + + expect(moved).toBe(original); + expect(other).not.toBe(original); + }), + ); + it.effect("issues and verifies signed browser session tokens", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index f8ea9550e..f4e5e3c48 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -21,11 +21,13 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, + resolveLegacySessionCookieName, resolveSessionCookieName, signPayload, timingSafeEqualBase64Url, @@ -360,6 +362,7 @@ export class SessionStore extends Context.Service< SessionStore, { readonly cookieName: string; + readonly legacyCookieName: string | undefined; readonly issue: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; @@ -475,18 +478,22 @@ function toAuthClientSession(input: Omit): AuthCli export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const secretStore = yield* ServerSecretStore.ServerSecretStore; const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); - const cookieName = resolveSessionCookieName({ + const cookieInput = { mode: serverConfig.mode, port: serverConfig.port, host: serverConfig.host, instanceKey: serverConfig.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: serverConfig.devUrl !== undefined, - }); + } as const; + const cookieName = resolveSessionCookieName(cookieInput); + const legacyCookieName = resolveLegacySessionCookieName(cookieInput); const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { @@ -959,6 +966,7 @@ export const make = Effect.gen(function* () { return SessionStore.of({ cookieName, + legacyCookieName, issue, verify, issueWebSocketToken, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 58277141a..cc74966c4 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, }); } +const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) => + Effect.fromResult( + Cookies.set(Cookies.empty, cookieName, token, { + expires: DateTime.toDate(expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe( + Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")), + Effect.flatMap((cookies) => + HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.mergeCookies(response, cookies)), + ), + ), + ); + export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( scope: AuthEnvironmentScope, ) { @@ -224,7 +241,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); const request = yield* HttpServerRequest.HttpServerRequest; - return yield* serverAuth.getSessionState(request); + const result = yield* serverAuth.getSessionState(request); + const credential = EnvironmentAuth.selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if ( + credential?.source === "legacy-cookie" && + result.authenticated && + result.sessionMethod === "browser-session-cookie" && + result.expiresAt + ) { + yield* appendSessionCookie(sessions.cookieName, credential.token, result.expiresAt); + yield* appendCredentialResponseHeaders; + } + return result; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -241,17 +273,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); - const sessionCookies = yield* Effect.fromResult( - Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { - expires: DateTime.toDate(result.response.expiresAt), - httpOnly: true, - path: "/", - sameSite: "lax", - }), - ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); - - yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), + yield* appendSessionCookie( + sessions.cookieName, + result.sessionToken, + result.response.expiresAt, ); yield* appendCredentialResponseHeaders; return result.response; diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 47ecc3ec5..75bc4bcdf 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -64,6 +64,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-one", + environmentId: "environment-one", development: true, }); const second = resolveSessionCookieName({ @@ -71,6 +72,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-two", + environmentId: "environment-two", development: true, }); @@ -79,25 +81,48 @@ describe("session cookie isolation", () => { expect(first).not.toBe(second); }); - it("keeps the hosted web cookie stable across server instances", () => { - expect( - resolveSessionCookieName({ - mode: "web", - port: 8080, - host: "0.0.0.0", - instanceKey: "/srv/release-a", - development: false, - }), - ).toBe("t3_session"); - expect( - resolveSessionCookieName({ - mode: "web", - port: 9090, - host: "app.example.com", - instanceKey: "/srv/release-b", - development: false, - }), - ).toBe("t3_session"); + it("isolates remote web servers by server state", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 3773, + host: "192.168.1.50", + instanceKey: "/srv/t3-one", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "192.168.1.50", + instanceKey: "/srv/t3-two", + environmentId: "environment-two", + development: false, + }); + + expect(first).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(second).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(first).not.toBe(second); + }); + + it("keeps a remote web server cookie stable across port changes", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 8080, + host: "0.0.0.0", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 9090, + host: "app.example.com", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + + expect(first).toBe(second); }); it("retains desktop port scoping", () => { @@ -107,6 +132,7 @@ describe("session cookie isolation", () => { port: 3773, host: "127.0.0.1", instanceKey: "/tmp/desktop", + environmentId: "environment-one", development: true, }), ).toBe("t3_session_3773"); @@ -119,6 +145,7 @@ describe("session cookie isolation", () => { port: 5775, host: "0.0.0.0", instanceKey: "/tmp/t3-wildcard-dev", + environmentId: "environment-one", development: true, }), ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 32a6799b0..30d59d654 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -16,40 +16,53 @@ const SESSION_COOKIE_NAME = "t3_session"; * clobbers the first's session and both sides see "Invalid session token * signature" until someone clears cookies by hand. * - * Two populations qualify, for the same reason but from different causes: + * Remote web servers use their persisted environment identity and omit the + * port, so the name survives state-directory moves and public port changes. * - * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. - * - **Desktop**, which scans upward from 3773 for a free port and binds + * Desktop scans upward from 3773 for a free port and binds * 127.0.0.1, so a second instance lands on a different port and the same host. - * - * Hosted deployments keep the stable production name: their public port can - * change between releases, and scoping it would log every user out. */ export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; readonly host: string | undefined; readonly instanceKey: string; + readonly environmentId: string; readonly development: boolean; }): string { if (input.mode === "desktop") { return `${SESSION_COOKIE_NAME}_${input.port}`; } + const instanceHash = NodeCrypto.createHash("sha256") + .update( + !input.development && isRemoteReachableHost(input.host) + ? input.environmentId + : input.instanceKey, + ) + .digest("hex") + .slice(0, 12); + if (!input.development && isRemoteReachableHost(input.host)) { - return SESSION_COOKIE_NAME; + return `${SESSION_COOKIE_NAME}_${instanceHash}`; } // Cookies are scoped by host, not port. Loopback development servers need an // instance-specific name or parallel agents overwrite each other's session, // and a server that later reuses the port receives a token signed elsewhere. - const instanceHash = NodeCrypto.createHash("sha256") - .update(input.instanceKey) - .digest("hex") - .slice(0, 12); return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; } +export function resolveLegacySessionCookieName(input: { + readonly mode: "web" | "desktop"; + readonly host: string | undefined; + readonly development: boolean; +}): string | undefined { + return input.mode === "web" && !input.development && isRemoteReachableHost(input.host) + ? SESSION_COOKIE_NAME + : undefined; +} + export function isRemoteReachableHost(host: string | undefined): boolean { if (host === "0.0.0.0" || host === "::" || host === "[::]") { return true; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index de93078a7..873037ca3 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -14,6 +14,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -27,7 +28,13 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; +import { + SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_LAUNCHER_PROTOCOL, +} from "./cloud/serviceProtocol.ts"; import * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; @@ -43,7 +50,24 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import packageJson from "../package.json" with { type: "json" }; + const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const DisconnectedLauncherChildLayer = Layer.mergeAll( + Layer.succeed(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Layer.succeed(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), +); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); @@ -347,6 +371,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef Layer.provideMerge( EnvironmentAuth.layer.pipe( Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(ServerSecretStore.layer), ), ), @@ -465,7 +490,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(status.linked, false); assert.equal(status.cloudUserId, null); assert.equal(status.relayUrl, null); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("reports actionable human-readable headless connect state", () => @@ -636,7 +661,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { "relay:write", ]); assert.equal("token" in (listed[0] ?? {}), false); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("rejects invalid ttl values before running auth commands", () => diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index c4aba1e3c..00b61c1c4 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -336,7 +336,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f return { status: "not-authenticated" } satisfies RelayUnlinkResult; } - const environment = yield* ServerEnvironment.ServerEnvironment; + const environment = yield* ServerEnvironment.ServerEnvironmentIdentity; const environmentId = yield* environment.getEnvironmentId; const relayUrl = yield* relayUrlConfig; const httpClient = yield* HttpClient.HttpClient; @@ -431,7 +431,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* , options?: { readonly quietLogs?: boolean; @@ -448,7 +448,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* { assert.equal(credentials.length, 1); assert.equal(credentials[0]?.label, "t3 pair"); }), - ).pipe(Effect.provide(NodeServices.layer)), + ).pipe( + Effect.provide(NodeServices.layer), + Effect.provideService(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), + ), ); it.effect("pairs through the recorded dev web URL for dev servers", () => diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 73f5088ea..4a19c55dc 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -71,6 +73,77 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { }); it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect.each([ + { name: "missing", content: undefined }, + { name: "empty", content: "" }, + { name: "whitespace-only", content: " \t\n" }, + ])("concurrent initializers recover a $name environment id file", ({ content }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-concurrent-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + if (content !== undefined) { + yield* fileSystem.writeFileString(serverConfig.environmentIdPath, content); + } + const bothGenerated = yield* Deferred.make(); + const bothReadEmpty = yield* Deferred.make(); + const firstInitialized = yield* Deferred.make(); + let remaining = 2; + let emptyReads = 0; + const readIdentity = Effect.gen(function* () { + const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; + return yield* identity.getEnvironmentId; + }).pipe( + Effect.tap(() => Deferred.succeed(firstInitialized, undefined)), + Effect.provide(Layer.fresh(ServerEnvironment.identityLayer)), + Effect.provideService(ServerConfig.ServerConfig, serverConfig), + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readFileString: (path) => + fileSystem.readFileString(path).pipe( + Effect.tap( + Effect.fn(function* (value) { + if (path !== serverConfig.environmentIdPath || remaining > 0 || value.trim()) { + return; + } + // Both observe the empty file, but one repairs it after the other has finished. + if (++emptyReads === 2) { + yield* Deferred.succeed(bothReadEmpty, undefined); + yield* Deferred.await(firstInitialized); + } else { + yield* Deferred.await(bothReadEmpty); + } + }), + ), + ), + }), + Effect.provideService(Crypto.Crypto, { + ...crypto, + randomUUIDv4: Effect.gen(function* () { + const id = yield* crypto.randomUUIDv4; + if (--remaining === 0) { + yield* Deferred.succeed(bothGenerated, undefined); + } + yield* Deferred.await(bothGenerated); + return id; + }), + }), + ); + + const [first, second] = yield* Effect.all([readIdentity, readIdentity], { + concurrency: "unbounded", + }); + const persisted = yield* fileSystem.readFileString(serverConfig.environmentIdPath); + + expect(first).toBe(second); + expect(persisted.trim()).toBe(first); + }), + ); + it.effect("persists the environment id across service restarts", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -196,6 +269,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }); const serverConfig = yield* makeServerConfig(baseDir); const environmentIdPath = serverConfig.environmentIdPath; + const tempPath = `${environmentIdPath}.tmp`; const methodByOperation = { check: "exists", read: "readFileString", @@ -215,6 +289,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { exists: () => operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"), readFileString: () => Effect.fail(cause), + makeTempFileScoped: () => Effect.succeed(tempPath), writeFileString: (path) => { writeAttempts.push(path); return Effect.fail(cause); @@ -244,7 +319,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(error.message).toBe( `Server environment ID ${operation} failed at '${environmentIdPath}'.`, ); - expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []); + expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []); } }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index ddbad1531..53159affc 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -25,12 +25,15 @@ import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.t export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( "ServerEnvironmentIdPersistenceError", { - operation: Schema.Literals(["check", "read", "write"]), + operation: Schema.Literals(["check", "read", "write", "initialize"]), environmentIdPath: Schema.String, - cause: Schema.Defect(), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { + if (this.operation === "initialize") { + return `Server environment ID file is missing or empty after initialization at '${this.environmentIdPath}'.`; + } return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`; } } @@ -43,6 +46,13 @@ export class ServerEnvironment extends Context.Service< } >()("t3/environment/ServerEnvironment") {} +export class ServerEnvironmentIdentity extends Context.Service< + ServerEnvironmentIdentity, + { + readonly getEnvironmentId: Effect.Effect; + } +>()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {} + function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { switch (platform) { case "darwin": @@ -69,14 +79,10 @@ function platformArch( } } -export const make = Effect.gen(function* () { +const makeIdentity = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; - const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( @@ -108,17 +114,42 @@ export const make = Effect.gen(function* () { return raw.length > 0 ? raw : null; }); - const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe( - Effect.mapError( - (cause) => - new ServerEnvironmentIdPersistenceError({ - operation: "write", - environmentIdPath: serverConfig.environmentIdPath, - cause, - }), - ), - ); + const persistEnvironmentId = Effect.fn("ServerEnvironmentIdentity.persistEnvironmentId")( + function* (value: string, mode: "create" | "recover") { + const destinationPath = + mode === "recover" + ? `${serverConfig.environmentIdPath}.recovery` + : serverConfig.environmentIdPath; + const tempPath = yield* fileSystem.makeTempFileScoped({ + directory: serverConfig.stateDir, + prefix: ".environment-id-", + }); + yield* fileSystem.writeFileString(tempPath, `${value}\n`); + // Publish the completed file without replacing an ID created by another process. + yield* fileSystem + .link(tempPath, destinationPath) + .pipe( + Effect.catch((cause) => + cause.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(cause), + ), + ); + if (mode === "recover") { + // Keep the recovery ID so delayed initializers also publish the same winner. + yield* fileSystem.remove(tempPath); + yield* fileSystem.copyFile(destinationPath, tempPath); + yield* fileSystem.rename(tempPath, serverConfig.environmentIdPath); + } + }, + Effect.scoped, + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "write", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); const environmentIdRaw = yield* Effect.gen(function* () { const persisted = yield* readPersistedEnvironmentId; @@ -127,11 +158,35 @@ export const make = Effect.gen(function* () { } const generated = yield* crypto.randomUUIDv4; - yield* persistEnvironmentId(generated); - return generated; + yield* persistEnvironmentId(generated, "create"); + let winner = yield* readPersistedEnvironmentId; + if (winner === null) { + yield* persistEnvironmentId(generated, "recover"); + winner = yield* readPersistedEnvironmentId; + } + if (winner === null) { + return yield* new ServerEnvironmentIdPersistenceError({ + operation: "initialize", + environmentIdPath: serverConfig.environmentIdPath, + }); + } + return winner; }); const environmentId = EnvironmentId.make(environmentIdRaw); + return ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(environmentId), + }); +}); + +export const make = Effect.gen(function* () { + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const identity = yield* ServerEnvironmentIdentity; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); const machine = yield* detectServerEnvironmentMachineKind(); @@ -203,10 +258,15 @@ export const make = Effect.gen(function* () { }); }); +export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentity); + /** * ServerEnvironment is acquired from persisted filesystem and host-process * state. It intentionally has no fallback Layer.succeed value: callers must * provide the external platform services, a ServerConfig, and the * ServerSecretStore backing the descriptor's publishing capability. */ -export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); +export const layer = Layer.effect(ServerEnvironment, make).pipe( + Layer.provideMerge(identityLayer), + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7abbc68b3..36e014c3f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -348,6 +348,11 @@ const makeAuthTestLayer = () => EnvironmentAuth.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + }), + ), ); const makeBrowserOtlpPayload = (spanName: string) => @@ -2246,6 +2251,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("migrates a valid legacy remote-web session cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const currentCookie = cookie?.split(";")[0] ?? ""; + const legacyCookie = currentCookie.replace(/^t3_session_[^=]+=/, "t3_session="); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: { cookie: legacyCookie }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.equal(response.headers["set-cookie"], cookie); + assert.equal(response.headers["cache-control"], "no-store"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect.each(["cookie", "bearer"])( + "does not migrate a stale legacy cookie when %s auth succeeds", + (source) => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const sessionCookie = cookie?.split(";")[0] ?? ""; + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: + source === "cookie" + ? { cookie: `${sessionCookie}; t3_session=stale` } + : { authorization: `Bearer ${sessionToken}`, cookie: "t3_session=stale" }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.isUndefined(response.headers["set-cookie"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2cc5e93f4..bd91fb89f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -426,8 +426,13 @@ const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( Layer.provide(T3ProjectFileLoader.layer), ); +const ServerEnvironmentLayerLive = ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), +); + const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), + Layer.provide(ServerEnvironmentLayerLive), Layer.provide(ServerSecretStore.layer), ); @@ -502,7 +507,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)), Layer.provideMerge(RepositoryIdentityResolver.layer), - Layer.provideMerge(ServerEnvironment.layer), + Layer.provideMerge(ServerEnvironmentLayerLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 95a024c9d..e9e218884 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -77,9 +77,11 @@ describe("clientPersistenceStorage", () => { }, ); - // T3 Code served from the same origin writes `confirmQuit` as "hold" | "direct" | "double-click". + // Another client served from the same origin, such as T3 Code, can write a + // value this build cannot decode next to values it can. const t3CodeDocument = JSON.stringify({ - confirmQuit: "hold", + confirmQuit: "double-click", + diffLayout: "unified", timestampFormat: "12-hour", wordWrap: false, }); @@ -93,6 +95,7 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual({ ...DEFAULT_CLIENT_SETTINGS, + confirmQuit: "double-click", timestampFormat: "12-hour", wordWrap: false, }); @@ -111,6 +114,7 @@ describe("clientPersistenceStorage", () => { await ensureClientSettingsHydrated(); expect(getClientSettings()).toEqual({ ...DEFAULT_CLIENT_SETTINGS, + confirmQuit: "double-click", timestampFormat: "12-hour", wordWrap: false, }); @@ -120,15 +124,16 @@ describe("clientPersistenceStorage", () => { onboardingCompletedAt: "2026-09-10T12:00:00.000Z", })); expect(storedDocument()).toMatchObject({ - confirmQuit: "hold", + confirmQuit: "double-click", + diffLayout: "unified", timestampFormat: "12-hour", onboardingCompletedAt: "2026-09-10T12:00:00.000Z", }); - await persistClientSettingsUpdate((current) => ({ ...current, confirmQuit: false })); - expect(storedDocument()).toMatchObject({ confirmQuit: false }); + await persistClientSettingsUpdate((current) => ({ ...current, diffLayout: "split" })); + expect(storedDocument()).toMatchObject({ diffLayout: "split" }); await persistClientSettingsUpdate((current) => ({ ...current, wordWrap: true })); - expect(storedDocument()).toMatchObject({ confirmQuit: false, wordWrap: true }); + expect(storedDocument()).toMatchObject({ diffLayout: "split", wordWrap: true }); }); it("preserves saved settings across a transient read failure", async () => { diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 1730030d6..3ae0dbd74 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -88,6 +88,7 @@ function registryLayer(options?: { const session: RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index adde26075..eff5ccfb5 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -427,7 +427,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { // Label widths can change without the strip box moving (font family or // size preferences), so re-measure on every render as well as on resize // and font loads. - useEffect(() => { + useLayoutEffect(() => { measure(); }); @@ -590,7 +590,7 @@ export const BranchToolbar = memo(function BranchToolbar({ ) : null}
diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6c7a61c8d..4685dbf1e 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -56,16 +56,21 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe data-composer-context-control > {activeWorktreePath ? ( - <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} - + ) : ( - <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} - + )} + + + {resolveLockedWorkspaceLabel(activeWorktreePath)} + + ); } diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ff205e4ea..c388891e7 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2268,11 +2268,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "project-settings", label: "Project settings" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], position, ); + if (clicked === "project-settings") { + if (isMobile) setOpenMobile(false); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey: project.projectKey }, + }); + return; + } + if (clicked === "new-thread-on-branch") { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -2355,9 +2365,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard, deleteThread, handleNewThread, + isMobile, markThreadUnread, memberProjectByScopedKey, + project.projectKey, project.workspaceRoot, + router, + setOpenMobile, startThreadRename, ], ); diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx index 7cc4d184f..2bca40f5b 100644 --- a/apps/web/src/components/QuitHoldOverlay.tsx +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -2,29 +2,34 @@ import { useEffect, useState } from "react"; import { isMacPlatform } from "../lib/utils"; -// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint -// from a quick tap lingers for as long as a full hold would have taken. -const HIDE_AFTER_RELEASE_MS = 1200; +// A released hold hint lingers for the original hold duration. Double-press +// hints disappear as soon as their acceptance window closes. +const HOLD_HINT_LINGER_MS = 1200; /** - * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts - * the quit accelerator and pushes press/release states; a quick tap shows - * this pill while a full hold quits the app. + * The desktop main process intercepts the quit accelerator and pushes + * press/release states while it waits for a hold or second press. */ export function QuitHoldOverlay() { - const [visible, setVisible] = useState(false); + const [visibleMode, setVisibleMode] = useState<"hold" | "double-click" | null>(null); useEffect(() => { const subscribe = window.desktopBridge?.onQuitShortcut; if (!subscribe) return; let hideTimer: number | undefined; - const unsubscribe = subscribe((state) => { + let pressedMode: "hold" | "double-click" = "hold"; + const unsubscribe = subscribe((hint) => { window.clearTimeout(hideTimer); - if (state === "down") { - setVisible(true); + if (hint.state === "down") { + pressedMode = hint.mode; + setVisibleMode(hint.mode); return; } - hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); + if (pressedMode === "double-click") { + setVisibleMode(null); + return; + } + hideTimer = window.setTimeout(() => setVisibleMode(null), HOLD_HINT_LINGER_MS); }); return () => { window.clearTimeout(hideTimer); @@ -32,15 +37,19 @@ export function QuitHoldOverlay() { }; }, []); - if (!visible) return null; + if (!visibleMode) return null; const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; + const message = + visibleMode === "hold" + ? `Hold ${shortcut} or press twice to quit` + : `Press ${shortcut} again to quit`; return (
- Hold {shortcut} or press twice to quit + {message}
); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index adfe6130d..d2a48ccc9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2333,6 +2333,8 @@ export default function Sidebar() { () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); + const projectGroupsRef = useRef(projectGroups); + projectGroupsRef.current = projectGroups; const serverConfigs = useAtomValue(environmentServerConfigsAtom); // Threads on non-primary environments (T3 Connect, hosted) resolve their // provider entry from their own environment's config: default instance ids @@ -2485,6 +2487,18 @@ export default function Sidebar() { clearSelection(); }, [clearSelection, projectScopeKey]); + const openProjectSettings = useCallback( + (projectGroup: SidebarProjectSnapshot) => { + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey: projectGroup.projectKey }, + }); + }, + [isMobile, router, setOpenMobile], + ); // Safari may dispatch a selection click after the context menu opens settings. const suppressNextScopeChangeRef = useRef(false); const highlightedProjectScopeKeyRef = useRef(null); @@ -2497,15 +2511,9 @@ export default function Sidebar() { event.stopPropagation(); suppressNextScopeChangeRef.current = true; dispatchProjectScopeMenu({ type: "project-settings-opened" }); - if (isMobile) { - setOpenMobile(false); - } - void router.navigate({ - to: "/projects/$projectKey", - params: { projectKey: projectGroup.projectKey }, - }); + openProjectSettings(projectGroup); }, - [isMobile, router, setOpenMobile], + [openProjectSettings], ); // Keep a dropped row at its destination while its server applies the @@ -4058,6 +4066,17 @@ export default function Sidebar() { return; } switch (clicked.value) { + case "project-settings": { + const projectGroup = projectGroupsRef.current.find((group) => + group.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === thread.environmentId && + projectRef.projectId === thread.projectId, + ), + ); + if (projectGroup) openProjectSettings(projectGroup); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -4217,6 +4236,7 @@ export default function Sidebar() { deleteThread, handleMultiSelectContextMenu, markThreadUnread, + openProjectSettings, projectByKey, serverConfigs, startThreadRename, diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 59e027c14..fbebc323a 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -317,13 +317,16 @@ export const ChatHeader = memo(function ChatHeader({ className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" onContextMenu={handleHeaderContextMenu} > - + {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone doesn't answer it. */} {activeProject ? ( <> - + } > @@ -344,7 +347,7 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} - + {renamingTitle !== null ? ( 0 - ) { - return "edit"; - } - if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) { - return "command"; - } - if (workLogEntryIsLocalCodeSearch(entry)) return "code-search"; - if (entry.itemType === "web_search") return "search"; - return workLogEntryIsToolLike(entry) ? "other" : "update"; -} - -function toolGroupActionCount( - action: ToolGroupAction, - entries: ReadonlyArray, -): number { - if (action !== "edit") return entries.length; - - const changedFiles = new Set(); - let editsWithoutFileDetails = 0; - for (const entry of entries) { - if (!entry.changedFiles || entry.changedFiles.length === 0) { - editsWithoutFileDetails += 1; - continue; - } - for (const file of entry.changedFiles) changedFiles.add(file); - } - return changedFiles.size + editsWithoutFileDetails; -} - -function toolGroupActionLabel(action: ToolGroupAction, count: number): string { - switch (action) { - case "read": - return `Read ${count} ${count === 1 ? "file" : "files"}`; - case "edit": - return `Changed ${count} ${count === 1 ? "file" : "files"}`; - case "command": - return `Ran ${count} ${count === 1 ? "command" : "commands"}`; - case "browser": - return `Used browser ${count} ${count === 1 ? "time" : "times"}`; - case "search": - return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; - case "code-search": - return `Searched code ${count} ${count === 1 ? "time" : "times"}`; - case "other": - return `Used ${count} ${count === 1 ? "tool" : "tools"}`; - case "update": - return `Received ${count} ${count === 1 ? "update" : "updates"}`; - } -} - -/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */ -function summarizeToolGroup(entries: ReadonlyArray): string { - const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry); - // A named source stands in for every call it made, so a run of Chrome steps - // reads as the integration rather than a tool count. - const sources = new Map>(); - const groupedEntries = new Map(); - for (const entry of summaryEntries) { - if (entry.toolSource) { - sources.set(entry.toolSource.key, entry.toolSource); - continue; - } - const action = toolGroupAction(entry); - const group = groupedEntries.get(action); - if (group) group.push(entry); - else groupedEntries.set(action, [entry]); - } - const labels = [...groupedEntries].map(([action, actionEntries]) => - toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), - ); - if (sources.size > 0) { - const sourceValues = [...sources.values()]; - const sourceNames = sourceValues.map((source) => source.name); - const formattedNames = - sourceNames.length < 2 - ? sourceNames[0]! - : sourceNames.length === 2 - ? sourceNames.join(" and ") - : `${sourceNames.slice(0, -1).join(", ")}, and ${sourceNames.at(-1)}`; - const allIntegrations = sourceValues.every((source) => source.kind === "integration"); - labels.unshift( - `Used ${formattedNames}${allIntegrations ? ` ${sources.size === 1 ? "integration" : "integrations"}` : ""}`, - ); - } - const sentenceLabels = labels.map((label, index) => - index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), - ); - if (sentenceLabels.length < 2) return sentenceLabels[0] ?? ""; - if (sentenceLabels.length === 2) return sentenceLabels.join(" and "); - return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; -} - -function omitSupersededLifecycleMarkers( - entries: readonly T[], - workEntryFor: (entry: T) => WorkLogEntry, -): T[] { - const laterTerminalIdentities = new Set(); - const reversedEntries: T[] = []; - - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]!; - const workEntry = workEntryFor(entry); - const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label); - const identity = [ - workEntry.turnId ?? "no-turn", - workEntry.itemType ?? "", - normalizedLabel, - ].join("\u001f"); - const isStatuslessIdlessMarker = - workEntry.toolCallId === undefined && - workEntry.toolLifecycleStatus === undefined && - (workEntry.sourceActivityKind === "tool.started" || - workEntry.sourceActivityKind === "tool.updated"); - if (isStatuslessIdlessMarker && laterTerminalIdentities.has(identity)) continue; - - reversedEntries.push(entry); - if ( - workEntry.sourceActivityKind === "tool.completed" || - (workEntry.toolLifecycleStatus !== undefined && - workEntry.toolLifecycleStatus !== "inProgress") - ) { - laterTerminalIdentities.add(identity); - } - } - - return reversedEntries.toReversed(); -} - -function toolGroupSummaryKind(entries: ReadonlyArray): ToolGroupSummaryKind { - const actions = new Set(entries.map(toolGroupAction)); - if (actions.size !== 1) return "mixed"; - - const action = actions.values().next().value!; - if (action !== "other") return action; - - const fallbackKinds = new Set( - entries.map((entry): ToolGroupSummaryKind => { - if (entry.itemType === "mcp_tool_call") return "other"; - if (entry.itemType === "dynamic_tool_call") return "dynamic-tool"; - if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent-tool"; - if (entry.tone === "thinking") return "agent-tool"; - if (entry.tone === "tool") return "tone-tool"; - return "other"; - }), - ); - return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; -} - function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f8073d305..c8befd4c9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -38,6 +38,7 @@ import { MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, + type QuitConfirmationMode, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; @@ -182,6 +183,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const QUIT_CONFIRMATION_MODE_LABELS: Record = { + direct: "Direct", + hold: "Hold", + "double-click": "Double press", +}; + const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { balanced: "Balanced", performance: "Performance", @@ -563,9 +570,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), - ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit - ? ["Quit confirmation"] - : []), + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ...getChangedBrowserSettingLabels(settings), ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess @@ -2712,11 +2717,11 @@ export function GeneralSettingsPanel() { {isElectron ? ( updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) } @@ -2724,11 +2729,29 @@ export function GeneralSettingsPanel() { ) : null } control={ - updateSettings({ confirmQuit: Boolean(checked) })} - aria-label="Hold to quit" - /> + } /> ) : null} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index af2b912d7..bc6796801 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -287,9 +287,9 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "quit-confirmation", - title: "Hold to quit", + title: "Quit shortcut", to: "/settings/general", - searchTerms: ["confirmation shortcut desktop app exit"], + searchTerms: ["confirmation desktop app exit direct hold double click press twice"], desktopOnly: true, }, { diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 96931bc8f..1bdd04693 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -33,7 +33,18 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy", "archive", "delete"]); + ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]); + }); + + it("groups project settings with utility actions before archive", () => { + const items = buildThreadActionMenuItems(baseState); + const copyIndex = items.findIndex((item) => item.id === "copy"); + expect(items[copyIndex + 1]).toMatchObject({ + id: "project-settings", + label: "Project settings", + icon: "settings", + }); + expect(items[copyIndex + 2]?.id).toBe("archive"); }); it("includes branch items only for threads with a branch", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index df983ee86..5ba266f77 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -8,6 +8,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" */ export type ThreadActionMenuId = | "new-thread-on-branch" + | "project-settings" | "pin" | "unpin" | "settle" @@ -119,6 +120,7 @@ export function buildThreadActionMenuItems( { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, ], }, + { id: "project-settings", label: "Project settings", icon: "settings" }, // 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 diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index f9a4ff757..b5e78287f 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -31,7 +31,13 @@ type ConnectionLayerSource = | typeof backgroundActivityReporterLayer; const providedClientConnectionLayer = snapshotLoaderLayer.pipe( - Layer.provideMerge(Connection.layer), + Layer.provideMerge( + Connection.layerWithOptions({ + environmentThemes: true, + usageLimitSources: true, + usageLimitsCommand: true, + }), + ), Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index d3bb9400c..c6cfc4498 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -96,6 +96,15 @@ const ICON_PATHS: Record void; }) { const { threadRef, projectCwd, onStartRename } = input; + const router = useRouter(); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const logicalProjectKeyByPhysicalKey = useMemo( + () => + buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }), + [primaryEnvironmentId, projectGroupingSettings, projects], + ); const { settleThread, unsettleThread, @@ -168,6 +190,22 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "project-settings": { + const project = projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && + candidate.id === thread.projectId, + ); + if (!project) return; + const projectKey = + logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey }, + }); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -300,10 +338,14 @@ export function useThreadActionMenu(input: { copyThreadIdToClipboard, deleteThread, handleNewThread, + logicalProjectKeyByPhysicalKey, markThreadUnread, onStartRename, pinThread, projectCwd, + projectGroupingSettings, + projects, + router, settleThread, snoozeThread, threadRef, diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 51cabcddc..7032f3fbf 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -41,6 +41,10 @@ It is identified by a stable `environmentId`, persisted by the server at `/environment-id.recovery` file so concurrent and delayed repairs choose +the same ID. Existing nonempty ID files remain authoritative. + ### Known environments and connection targets A saved client-side entry for an environment the client knows how to reach. It is not diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 91d2ce566..7e4250efe 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -103,14 +103,22 @@ but the new thread does not reuse the worktree created for the thread that just ## Desktop quit shortcut -Use `Cmd+Q` on macOS or `Ctrl+Q` on Windows and Linux. With **Hold to quit** enabled, -hold the shortcut for 1.2 seconds or press it twice within 500 milliseconds. The second -press quits immediately. You can keep Command or Control held between presses, or release -both keys. An unrelated shortcut cancels the first tap. +Use `Cmd+Q` on macOS or `Ctrl+Q` on Windows and Linux. **Settings** → **General** → +**Confirmations** → **Quit shortcut** chooses how it confirms: + +- **Hold** (the default): hold the shortcut for 1.2 seconds, or press it twice within + 500 milliseconds. A single quick press shows a hint instead of quitting. +- **Double press**: press the shortcut twice within 500 milliseconds. The first press + shows a hint until that window ends. +- **Direct**: the first press quits. + +The second press quits immediately. You can keep Command or Control held between presses, +or release both keys. An unrelated shortcut cancels the first press. Holding needs keyboard repeat. If holding does not quit, use two quick presses or choose -**Quit** from the application menu. Turn off **Hold to quit** in **Settings** → **General** -to quit on the first press. The application menu's **Quit** action always quits immediately. +**Quit** from the application menu. The application menu's **Quit** action always quits +immediately. If Pylon had **Hold to quit** turned off before this setting existed, it starts +in **Direct**. ## `when` Conditions diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 897754a6f..4785ae38a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -1,8 +1,8 @@ # Project settings Open **Settings → Projects**. The project and machine pickers start at **All projects** and -**All machines**. You can also open a project's settings from the sidebar project filter, the -chat header, or the command palette. +**All machines**. You can also open a project's settings from the sidebar project filter, a +thread's menu, the chat header, or the command palette. With **All projects** selected, change the default model, workspace, automatic pull, agent browser access, or actions for projects that inherit those values. Select an individual project to override diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 9a55de68e..2fe1b305b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -56,8 +56,8 @@ search results: the thread opens and the files are attached in its composer, rea message. Nothing is sent automatically. The same per-message file limits apply as when attaching files directly; see [Composer](./composer.md). -Project settings are available from the project menu in either sidebar and from the breadcrumb -context menu when composing a new thread. +Project settings are available from the project menu in either sidebar, from any thread's menu, +and from the breadcrumb context menu when composing a new thread. ## Mobile thread list diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index c49f95a8c..43153838d 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -11,33 +11,32 @@ import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as RpcSession from "../rpc/session.ts"; -const driverLayer = ConnectionDriver.layer.pipe( - Layer.provide(Layer.mergeAll(ConnectionResolver.layer, RpcSession.layer)), -); +export function layerWithOptions(options: RpcSession.RpcSessionOptions) { + const driverLayer = ConnectionDriver.layer.pipe( + Layer.provide(Layer.mergeAll(ConnectionResolver.layer, RpcSession.layerWithOptions(options))), + ); + const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); + const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); + const connectionServicesLayer = Layer.mergeAll( + registryLayer, + RelayEnvironmentDiscovery.layer, + onboardingLayer, + ); + const connectionStartupLayer = Layer.effectDiscard( + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; + yield* registry.start; + yield* platformSource.registrations.pipe( + Stream.runForEach(registry.reconcilePlatform), + Effect.forkScoped, + ); + }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), + ); + return connectionStartupLayer.pipe( + Layer.provideMerge(connectionServicesLayer), + Layer.provideMerge(RemoteEnvironmentAuthorization.layer), + ); +} -const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); - -const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); - -const connectionServicesLayer = Layer.mergeAll( - registryLayer, - RelayEnvironmentDiscovery.layer, - onboardingLayer, -); - -const connectionStartupLayer = Layer.effectDiscard( - Effect.gen(function* () { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; - yield* registry.start; - yield* platformSource.registrations.pipe( - Stream.runForEach(registry.reconcilePlatform), - Effect.forkScoped, - ); - }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), -); - -export const layer = connectionStartupLayer.pipe( - Layer.provideMerge(connectionServicesLayer), - Layer.provideMerge(RemoteEnvironmentAuthorization.layer), -); +export const layer = layerWithOptions({}); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 258927fc0..d756eeaf1 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -357,6 +357,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( Effect.succeed({ client: {} as RpcSession.RpcSession["client"], initialConfig: Effect.die(new Error("Config is not used by registry tests.")), + subscribeServerConfig: () => + Stream.die(new Error("Config is not used by registry tests.")), ready: Effect.void, probe: Effect.void, closed: Deferred.await(closed), diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 53ad4bd20..2da7a68bd 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -174,6 +174,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: Effect.succeed({ client: TEST_RPC_CLIENT, initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), + subscribeServerConfig: (input) => TEST_RPC_CLIENT.subscribeServerConfig(input), ready: options?.ready?.(attempt) ?? Effect.void, probe: options?.probe?.(attempt) ?? Effect.void, closed: Deferred.await(closed), diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 6d87a6e85..5cc175864 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -58,6 +58,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct const session: RpcSession.RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 7f7944c4a..467291390 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -1,9 +1,11 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, ThreadId, type ServerConfig, type PreviewSessionSnapshot, type RelayClientInstallProgressEvent, + type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, WS_METHODS, } from "@t3tools/contracts"; @@ -59,6 +61,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -89,6 +92,39 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect("reuses the session config stream instead of opening a duplicate subscription", () => + Effect.gen(function* () { + const event: ServerConfigStreamEvent = { + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + }; + let duplicateSubscriptions = 0; + const client = { + [WS_METHODS.subscribeServerConfig]: () => { + duplicateSubscriptions += 1; + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set( + activeSession, + Option.some({ + ...session(client), + subscribeServerConfig: () => Stream.succeed(event), + }), + ); + + const received = yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe( + Stream.runHead, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(received).toEqual(Option.some(event)); + expect(duplicateSubscriptions).toBe(0); + }), + ); + for (const [profileId, supported, allowed] of [ [undefined, undefined, true], ["default", undefined, true], diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index ca4644dc1..6e529a135 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -239,7 +239,11 @@ function subscribeDynamicMapped( Option.match({ onNone: () => Stream.empty, onSome: (session) => { - const method = session.client[tag] as ( + const method = ( + tag === WS_METHODS.subscribeServerConfig + ? session.subscribeServerConfig + : session.client[tag] + ) as ( input: EnvironmentRpcInput, ) => Stream.Stream< EnvironmentRpcStreamValue, diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index caeae18d4..cd843c1f7 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -1,25 +1,43 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, ServerConfig, type ServerConfig as ServerConfigType, + ServerConfigStreamEvent, + type ServerConfigStreamEvent as ServerConfigStreamEventType, WS_METHODS, + UsageLimitSourceId, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as TestClock from "effect/testing/TestClock"; import * as Socket from "effect/unstable/socket/Socket"; import { + AVAILABLE_CONNECTION_STATE, + ConnectionBlockedError, ConnectionTransientError, PrimaryConnectionTarget, RelayConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "./session.ts"; +import { makeEnvironmentServerConfigState } from "../state/server.ts"; +import { applyServerConfigProjection } from "../state/serverConfigProjection.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; type SocketEventType = "open" | "message" | "close" | "error"; @@ -141,10 +159,47 @@ const RpcRequest = Schema.TaggedStruct("Request", { tag: Schema.String, }); const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); +const isRpcRequest = Schema.is(RpcRequest); +const isPing = Schema.is(Schema.Struct({ _tag: Schema.Literal("Ping") })); const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encodeServerConfig = Schema.encodeSync(ServerConfig); +const encodeServerConfigStreamEvent = Schema.encodeSync(ServerConfigStreamEvent); +const encodeDefect = Schema.encodeSync(Schema.Defect()); const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG); +const THEME_SERVER_CONFIG: ServerConfigType = { + ...SERVER_CONFIG, + environment: { + ...SERVER_CONFIG.environment, + capabilities: { + ...SERVER_CONFIG.environment.capabilities, + environmentThemes: true, + }, + }, +}; +const ENCODED_THEME_SERVER_CONFIG = encodeServerConfig(THEME_SERVER_CONFIG); +const SOURCE_SERVER_CONFIG: ServerConfigType = { + ...THEME_SERVER_CONFIG, + environment: { + ...THEME_SERVER_CONFIG.environment, + capabilities: { ...THEME_SERVER_CONFIG.environment.capabilities, usageLimitSources: true }, + }, +}; +const SOURCE_EVENT: ServerConfigStreamEventType = { + version: 1, + type: "usageLimitSourcesUpdated", + payload: { + sources: [ + { + id: UsageLimitSourceId.make("proxy"), + kind: "cliproxy", + label: "Proxy", + checkedAt: "2026-09-04T00:00:00Z", + accounts: [], + }, + ], + }, +}; + const LEGACY_SERVER_CONFIG = { ...ENCODED_SERVER_CONFIG, environment: { @@ -155,23 +210,26 @@ const LEGACY_SERVER_CONFIG = { }, }; -const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { +const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* ( + options: RpcSession.RpcSessionOptions = {}, +) { const sockets: TestWebSocket[] = []; const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { const socket = new TestWebSocket(url); sockets.push(socket); return socket as unknown as globalThis.WebSocket; }); - const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); + const layer = RpcSession.layerWithOptions(options).pipe(Layer.provide(constructorLayer)); const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); return { factory, sockets }; }); const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( sockets: ReadonlyArray, + index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const socket = sockets[0]; + const socket = sockets[index]; if (socket) { return socket; } @@ -185,9 +243,9 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const request = socket.sent[index]; + const request = socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)[index]; if (request) { - return decodeRpcRequest(decodeJson(request)); + return request; } yield* Effect.yieldNow; } @@ -197,21 +255,33 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( socket: TestWebSocket, config: unknown = ENCODED_SERVER_CONFIG, + payload: unknown = {}, ) { const request = yield* awaitRequest(socket); expect(request).toMatchObject({ _tag: "Request", - tag: WS_METHODS.serverGetConfig, - payload: {}, + tag: WS_METHODS.subscribeServerConfig, + payload, }); socket.serverMessage( encodeJson({ - _tag: "Exit", + _tag: "Chunk", requestId: request.id, - exit: { - _tag: "Success", - value: config, - }, + values: [{ version: 1, type: "snapshot", config }], + }), + ); +}); + +const publishConfigEvents = Effect.fn("TestRpcSessionFactory.publishConfigEvents")(function* ( + socket: TestWebSocket, + events: ReadonlyArray, +) { + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: events.map((event) => encodeServerConfigStreamEvent(event)), }), ); }); @@ -231,7 +301,9 @@ describe("RpcSessionFactory", () => { const config = yield* session.initialConfig; expect(config).toEqual(SERVER_CONFIG); - expect(socket.sent).toHaveLength(1); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); const probeFiber = yield* Effect.forkChild(session.probe); const probeRequest = yield* awaitRequest(socket, 1); @@ -252,19 +324,25 @@ describe("RpcSessionFactory", () => { ); yield* Fiber.join(probeFiber); - expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ - WS_METHODS.serverGetConfig, - WS_METHODS.serverProbe, - ]); + expect( + socket.sent + .map((message) => decodeJson(message)) + .filter(isRpcRequest) + .map((request) => request.tag), + ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverProbe]); socket.close(1012, "service restart"); const error = yield* Effect.flip(session.closed); + const configStreamError = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runDrain, Effect.flip); expect(error).toBeInstanceOf(ConnectionTransientError); expect(error).toMatchObject({ reason: "transport", message: "Test environment disconnected.", }); + expect(configStreamError).toMatchObject({ _tag: "RpcClientError" }); yield* Effect.yieldNow; expect(sockets).toHaveLength(1); }), @@ -289,6 +367,699 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("replays current config and broadcasts updates to every subscriber", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const collectTwo = session + .subscribeServerConfig({}) + .pipe(Stream.take(2), Stream.runCollect); + const firstSubscriber = yield* Effect.forkChild(collectTwo); + const secondSubscriber = yield* Effect.forkChild(collectTwo); + yield* Effect.yieldNow; + + const shortcut = { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }; + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [ + { + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [{ command: "terminal.toggle", shortcut }], + issues: [], + }, + }, + ], + }), + ); + + const firstEvents = Array.from(yield* Fiber.join(firstSubscriber)); + const secondEvents = Array.from(yield* Fiber.join(secondSubscriber)); + expect(firstEvents.map((event) => event.type)).toEqual(["snapshot", "keybindingsUpdated"]); + expect(secondEvents).toEqual(firstEvents); + + const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); + expect(replay).toMatchObject({ + _tag: "Some", + value: { + type: "snapshot", + config: { keybindings: [{ command: "terminal.toggle", shortcut }] }, + }, + }); + }), + ), + ); + + for (const options of [ + { environmentThemes: true }, + { usageLimitSources: true }, + { environmentThemes: true, usageLimitSources: true }, + ]) { + it.effect( + `shares only a config subscription with the same opt-ins: ${JSON.stringify(options)}`, + () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(options); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options); + yield* Fiber.join(readyFiber); + + const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead); + expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); + expect( + socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest), + ).toHaveLength(1); + + const fallbackFiber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runHead, Effect.forkChild); + const fallbackRequest = yield* awaitRequest(socket, 1); + expect(fallbackRequest).toMatchObject({ + tag: WS_METHODS.subscribeServerConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: fallbackRequest.id, + values: [ + { + version: 1, + type: "snapshot", + config: ENCODED_THEME_SERVER_CONFIG, + }, + ], + }), + ); + expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ + _tag: "Some", + value: { type: "snapshot" }, + }); + }), + ), + ); + } + + it.effect.each([ + { usageLimitSources: true }, + { environmentThemes: true, usageLimitSources: true }, + ])("replays usage sources, removal, and capability downgrade with %j", (options) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(options); + const session = yield* factory.connect(PREPARED); + const ready = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, encodeServerConfig(SOURCE_SERVER_CONFIG), options); + yield* Fiber.join(ready); + const observed = yield* Queue.unbounded(); + yield* session.subscribeServerConfig(options).pipe( + Stream.runForEach((event) => Queue.offer(observed, event)), + Effect.forkChild, + ); + expect((yield* Queue.take(observed)).type).toBe("snapshot"); + const themes: ServerConfigStreamEventType[] = options.environmentThemes + ? [{ version: 1, type: "environmentThemesUpdated", payload: { themes: [] } }] + : []; + for (const event of themes) { + yield* publishConfigEvents(socket, [event]); + expect(yield* Queue.take(observed)).toEqual(event); + } + const events: ServerConfigStreamEventType[] = [ + SOURCE_EVENT, + { version: 1, type: "usageLimitSourcesUpdated", payload: { sources: [] } }, + SOURCE_EVENT, + { version: 1, type: "snapshot", config: THEME_SERVER_CONFIG }, + ]; + for (const event of events) { + yield* publishConfigEvents(socket, [event]); + expect(yield* Queue.take(observed)).toEqual(event); + const started = yield* Deferred.make(); + const replay = yield* session.subscribeServerConfig(options).pipe( + Stream.tap(() => Deferred.succeed(started, undefined)), + Stream.takeUntil((item) => item.type === "keybindingsUpdated"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(started); + // A live end marker makes a missing or stale replay event fail without a timeout. + const marker: ServerConfigStreamEventType = { + version: 1, + type: "keybindingsUpdated", + payload: { keybindings: [], issues: [] }, + }; + yield* publishConfigEvents(socket, [marker]); + expect(yield* Queue.take(observed)).toEqual(marker); + const replayed = Array.from(yield* Fiber.join(replay)); + expect(replayed.slice(1)).toEqual([ + ...themes, + ...(event.type === "snapshot" ? [] : [event]), + marker, + ]); + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: SOURCE_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, SOURCE_EVENT); + for (const item of replayed) projection = applyServerConfigProjection(projection, item); + expect(Option.getOrThrow(projection).config.usageLimitSources).toEqual( + event.type === "usageLimitSourcesUpdated" && event.payload.sources.length > 0 + ? event.payload.sources + : undefined, + ); + } + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); + }), + ), + ); + + it.effect("replays theme updates and deletion as authoritative events", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const firstThemes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ]; + const replacementThemes = [ + { + id: "midnight", + name: "Midnight", + appearance: "dark" as const, + canvas: "#000000", + accent: "#ffffff", + }, + ]; + const subscriberStarted = yield* Deferred.make(); + const subscriber = yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( + Stream.mapEffect((event) => + Deferred.succeed(subscriberStarted, undefined).pipe(Effect.as(event)), + ), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(subscriberStarted); + yield* publishConfigEvents(socket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: replacementThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }, + ]); + + const liveEvents = Array.from(yield* Fiber.join(subscriber)); + expect(liveEvents.map((event) => event.type)).toEqual([ + "snapshot", + "environmentThemesUpdated", + "environmentThemesUpdated", + "environmentThemesUpdated", + ]); + expect(liveEvents[2]).toMatchObject({ payload: { themes: replacementThemes } }); + + const replay = Array.from( + yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.take(2), Stream.runCollect), + ); + expect(replay.map((event) => event.type)).toEqual(["snapshot", "environmentThemesUpdated"]); + expect(replay[1]).toMatchObject({ payload: { themes: [] } }); + + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: THEME_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }); + for (const event of replay) { + projection = applyServerConfigProjection(projection, event); + } + expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); + }), + ), + ); + + it.effect("recovers a slow subscriber after it misses theme and usage-source deletion", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ + environmentThemes: true, + usageLimitSources: true, + }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, encodeServerConfig(SOURCE_SERVER_CONFIG), { + environmentThemes: true, + usageLimitSources: true, + }); + yield* Fiber.join(readyFiber); + + const slowSubscriberStarted = yield* Deferred.make(); + const releaseSlowSubscriber = yield* Deferred.make(); + let firstEvent = true; + const slowSubscriber = yield* session + .subscribeServerConfig({ environmentThemes: true, usageLimitSources: true }) + .pipe( + Stream.mapEffect((event) => { + if (!firstEvent) return Effect.succeed(event); + firstEvent = false; + return Deferred.succeed(slowSubscriberStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSlowSubscriber)), + Effect.as(event), + ); + }), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(slowSubscriberStarted); + + const firstThemes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ]; + const themeEvents: ServerConfigStreamEventType[] = [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { + themes: [{ ...firstThemes[0]!, name: "Nightfall 2" }], + }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }, + ]; + const settingsEvents = Array.from( + { length: 65 }, + (): ServerConfigStreamEventType => ({ + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + }), + ); + const sourceEvents: ServerConfigStreamEventType[] = [ + SOURCE_EVENT, + { version: 1, type: "usageLimitSourcesUpdated", payload: { sources: [] } }, + ]; + const allEvents = [...themeEvents, ...sourceEvents, ...settingsEvents]; + const observedByFastSubscriber = yield* Queue.unbounded(); + yield* session + .subscribeServerConfig({ environmentThemes: true, usageLimitSources: true }) + .pipe( + Stream.runForEach((event) => Queue.offer(observedByFastSubscriber, event)), + Effect.forkChild, + ); + expect((yield* Queue.take(observedByFastSubscriber)).type).toBe("snapshot"); + for (const event of allEvents) { + yield* publishConfigEvents(socket, [event]); + expect(yield* Queue.take(observedByFastSubscriber)).toEqual(event); + } + yield* Deferred.succeed(releaseSlowSubscriber, undefined); + + const recovered = Array.from(yield* Fiber.join(slowSubscriber)); + expect(recovered.map((event) => event.type)).toEqual([ + "snapshot", + "snapshot", + "environmentThemesUpdated", + "usageLimitSourcesUpdated", + ]); + expect(recovered[2]).toMatchObject({ payload: { themes: [] } }); + expect(recovered[3]).toMatchObject({ payload: { sources: [] } }); + + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: SOURCE_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, themeEvents[0]!); + projection = applyServerConfigProjection(projection, SOURCE_EVENT); + for (const event of recovered.slice(1)) { + projection = applyServerConfigProjection(projection, event); + } + expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); + expect(Option.getOrThrow(projection).config.usageLimitSources).toBeUndefined(); + }), + ), + ); + + it.effect("closes the session when the config source dies", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const closedFiber = yield* session.closed.pipe(Effect.exit, Effect.forkChild); + socket.serverMessage( + encodeJson({ + _tag: "Defect", + defect: encodeDefect(new Error("config stream died")), + }), + ); + + const closed = yield* Fiber.join(closedFiber); + expect(Exit.isFailure(closed)).toBe(true); + if (Exit.isFailure(closed)) { + expect(Cause.hasDies(closed.cause)).toBe(true); + } + }), + ), + ); + + it.effect.each([{ failure: "defect" as const }, { failure: "typed" as const }])( + "keeps durable config state alive after an owned $failure failure", + ({ failure }) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const firstSession = yield* factory.connect(PREPARED); + const firstReady = yield* Effect.forkChild(firstSession.ready); + const firstSocket = yield* awaitSocket(sockets); + firstSocket.open(); + yield* completeInitialConfig(firstSocket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(firstReady); + + const activeSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const configState = yield* makeEnvironmentServerConfigState({ + environmentThemes: true, + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + const awaitConfig = (predicate: (config: ServerConfigType) => boolean) => + SubscriptionRef.changes(configState).pipe( + Stream.filter(Option.isSome), + Stream.map((projection) => projection.value.config), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + + const firstThemes = [ + { + id: "first-theme", + name: "First theme", + appearance: "dark" as const, + canvas: "#111111", + accent: "#ffffff", + }, + ]; + const firstThemeState = yield* awaitConfig( + (config) => config.environmentThemes?.[0]?.id === "first-theme", + ).pipe(Effect.forkChild); + yield* publishConfigEvents(firstSocket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + ]); + expect((yield* Fiber.join(firstThemeState)).environmentThemes).toEqual(firstThemes); + + const firstClosed = yield* firstSession.closed.pipe(Effect.exit, Effect.forkChild); + const firstRequest = yield* awaitRequest(firstSocket); + firstSocket.serverMessage( + failure === "defect" + ? encodeJson({ + _tag: "Defect", + defect: encodeDefect(new Error("config stream died")), + }) + : encodeJson({ + _tag: "Exit", + requestId: firstRequest.id, + exit: { + _tag: "Failure", + cause: [ + { + _tag: "Fail", + error: { + _tag: "EnvironmentAuthorizationError", + message: "config subscription rejected", + requiredScope: "orchestration:read", + }, + }, + ], + }, + }), + ); + const firstClosedExit = yield* Fiber.join(firstClosed); + expect(Exit.isFailure(firstClosedExit)).toBe(true); + if (failure === "typed" && Exit.isFailure(firstClosedExit)) { + expect(Cause.squash(firstClosedExit.cause)).toBeInstanceOf(ConnectionBlockedError); + expect(Cause.squash(firstClosedExit.cause)).toMatchObject({ reason: "permission" }); + } + yield* SubscriptionRef.set(activeSession, Option.none()); + + const recoveredConfig = { + ...THEME_SERVER_CONFIG, + environment: { + ...THEME_SERVER_CONFIG.environment, + label: "Recovered environment", + }, + } satisfies ServerConfigType; + const secondSession = yield* factory.connect(PREPARED); + const secondReady = yield* Effect.forkChild(secondSession.ready); + const secondSocket = yield* awaitSocket(sockets, 1); + secondSocket.open(); + yield* completeInitialConfig(secondSocket, encodeServerConfig(recoveredConfig), { + environmentThemes: true, + }); + yield* Fiber.join(secondReady); + + const recoveredState = yield* awaitConfig( + (config) => config.environment.label === "Recovered environment", + ).pipe(Effect.forkChild); + yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); + expect((yield* Fiber.join(recoveredState)).environmentThemes).toEqual(firstThemes); + + const recoveredThemes = [ + { + id: "recovered-theme", + name: "Recovered theme", + appearance: "dark" as const, + canvas: "#000000", + accent: "#eeeeee", + }, + ]; + const liveRecoveredState = yield* awaitConfig( + (config) => config.environmentThemes?.[0]?.id === "recovered-theme", + ).pipe(Effect.forkChild); + yield* publishConfigEvents(secondSocket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: recoveredThemes }, + }, + ]); + expect((yield* Fiber.join(liveRecoveredState)).environmentThemes).toEqual( + recoveredThemes, + ); + }), + ), + ); + + it.effect.each<{ + readonly event: ServerConfigStreamEventType; + readonly expectedConfig: Partial; + }>([ + { + event: { + version: 1, + type: "providerStatuses", + payload: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + }, + }, + expectedConfig: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + }, + }, + { + event: { + version: 1, + type: "settingsUpdated", + payload: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, + }, + }, + }, + expectedConfig: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, + }, + }, + }, + ])( + "preserves $event.type events and includes them in replay snapshots", + ({ event, expectedConfig }) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const subscriber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkChild); + yield* Effect.yieldNow; + + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [encodeServerConfigStreamEvent(event)], + }), + ); + + const events = Array.from(yield* Fiber.join(subscriber)); + expect(events[1]).toEqual(event); + + const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); + expect(replay).toMatchObject({ + _tag: "Some", + value: { + type: "snapshot", + config: expectedConfig, + }, + }); + }), + ), + ); + it.effect("tolerates two missed pong windows before closing the session", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); @@ -303,7 +1074,7 @@ describe("RpcSessionFactory", () => { yield* TestClock.adjust("15 seconds"); expect(closedFiber.pollUnsafe()).toBeUndefined(); - expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([ + expect(socket.sent.map((message) => decodeJson(message)).filter(isPing)).toEqual([ { _tag: "Ping" }, { _tag: "Ping" }, { _tag: "Ping" }, @@ -381,10 +1152,12 @@ describe("RpcSessionFactory", () => { ); yield* Fiber.join(probeFiber); - expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ - WS_METHODS.serverGetConfig, - WS_METHODS.serverGetConfig, - ]); + expect( + socket.sent + .map((message) => decodeJson(message)) + .filter(isRpcRequest) + .map((request) => request.tag), + ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverGetConfig]); }), ), ); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 23be6d01d..8e1aabb21 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -1,12 +1,26 @@ -import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import { + type ServerConfig, + type ServerConfigStreamEvent, + WsSubscribeServerConfigRpc, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import type * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as Socket from "effect/unstable/socket/Socket"; @@ -21,15 +35,30 @@ import { ConnectionBlockedError, ConnectionTransientError as ConnectionTransientErrorClass, } from "../connection/model.ts"; +import { + applyServerConfigProjection, + type ServerConfigProjection, + withoutEnvironmentThemes, +} from "../state/serverConfigProjection.ts"; const SOCKET_OPEN_TIMEOUT = "15 seconds"; export interface RpcSession { readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; + readonly subscribeServerConfig: ( + input: ServerConfigSubscriptionInput, + ) => ServerConfigSubscription; readonly ready: Effect.Effect; readonly probe: Effect.Effect; - readonly closed: Effect.Effect; + readonly closed: Effect.Effect; +} + +export interface RpcSessionOptions { + readonly environmentThemes?: boolean; + readonly usageLimitSources?: boolean; + /** This client answers /usage-limits itself, so the server may advertise it. */ + readonly usageLimitsCommand?: boolean; } export class RpcSessionFactory extends Context.Service< @@ -45,11 +74,57 @@ type InitialConfigError = Effect.Error< ReturnType >; type ProbeError = Effect.Error>; +type ServerConfigSubscriptionError = + | Rpc.ErrorExit + | RpcClientError.RpcClientError; +type ServerConfigSubscription = Stream.Stream< + ServerConfigStreamEvent, + ServerConfigSubscriptionError +>; +type ServerConfigSubscriptionInput = Parameters< + WsRpcProtocolClient[typeof WS_METHODS.subscribeServerConfig] +>[0]; +type EnvironmentThemesUpdatedEvent = Extract< + ServerConfigStreamEvent, + { readonly type: "environmentThemesUpdated" } +>; +type UsageLimitSourcesUpdatedEvent = Extract< + ServerConfigStreamEvent, + { readonly type: "usageLimitSourcesUpdated" } +>; + +interface ServerConfigReplayState { + readonly projection: ServerConfigProjection; + readonly revision: number; + readonly themesEvent: EnvironmentThemesUpdatedEvent | undefined; + readonly sourcesEvent: UsageLimitSourcesUpdatedEvent | undefined; +} + +interface BufferedServerConfigEvent { + readonly event: ServerConfigStreamEvent; + readonly replay: ServerConfigReplayState; + readonly revision: number; +} + +function serverConfigReplayEvents( + state: ServerConfigReplayState, +): ReadonlyArray { + const snapshot = { + version: 1 as const, + type: "snapshot" as const, + config: withoutEnvironmentThemes(state.projection.config), + }; + return [ + snapshot, + ...(state.themesEvent === undefined ? [] : [state.themesEvent]), + ...(state.sourcesEvent === undefined ? [] : [state.sourcesEvent]), + ]; +} const isSocketErrorReason = Schema.is(Socket.SocketErrorReason); function mapSessionRpcError( - error: InitialConfigError | ProbeError, + error: InitialConfigError | ProbeError | ServerConfigSubscriptionError, networkHint: string, ): ConnectionAttemptError { switch (error._tag) { @@ -72,8 +147,13 @@ function mapSessionRpcError( } } -const make = Effect.gen(function* () { +const make = Effect.fn("RpcSessionFactory.make")(function* (options: RpcSessionOptions = {}) { const webSocketConstructor = yield* Socket.WebSocketConstructor; + const serverConfigInput: ServerConfigSubscriptionInput = { + ...(options.environmentThemes === true ? { environmentThemes: true } : {}), + ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), + }; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { const networkHint = @@ -126,18 +206,142 @@ const make = Effect.gen(function* () { const protocolContext = yield* Layer.build(protocolLayer).pipe( Effect.withSpan("environment.websocket.connect"), ); - const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); - const initialConfig = yield* Effect.cached( - client[WS_METHODS.serverGetConfig]({}).pipe( + const protocolClient = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const initialConfigDeferred = yield* Deferred.make(); + const serverConfigExit = yield* Deferred.make(); + const configSubscriptionClosed = yield* Deferred.make(); + const serverConfigState = yield* Ref.make(Option.none()); + const serverConfigUpdates = yield* PubSub.sliding(64); + const configSubscriptionEndedError = new ConnectionTransientErrorClass({ + reason: "remote-unavailable", + detail: `${connection.label} config subscription ended.`, + }); + const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]( + serverConfigInput, + ).pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + const buffered = yield* Ref.modify(serverConfigState, (current) => { + const projection = applyServerConfigProjection( + Option.map(current, (state) => state.projection), + event, + ); + if (Option.isNone(projection)) { + return [Option.none(), current] as const; + } + const next = { + projection: projection.value, + revision: Option.match(current, { + onNone: () => 1, + onSome: (state) => state.revision + 1, + }), + themesEvent: + event.type === "environmentThemesUpdated" + ? event + : event.type === "snapshot" && + event.config.environment.capabilities.environmentThemes !== true + ? undefined + : Option.getOrUndefined(current)?.themesEvent, + sourcesEvent: + event.type === "usageLimitSourcesUpdated" + ? event + : event.type === "snapshot" && + event.config.environment.capabilities.usageLimitSources !== true + ? undefined + : Option.getOrUndefined(current)?.sourcesEvent, + } satisfies ServerConfigReplayState; + return [ + Option.some({ event, replay: next, revision: next.revision }), + Option.some(next), + ] as const; + }); + if (Option.isSome(buffered)) { + yield* PubSub.publish(serverConfigUpdates, buffered.value); + } + if (event.type === "snapshot") { + yield* Deferred.succeed(initialConfigDeferred, event.config); + } + }), + ), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) { + return Effect.all([ + Deferred.succeed(serverConfigExit, undefined), + Deferred.fail(configSubscriptionClosed, configSubscriptionEndedError), + ]).pipe(Effect.asVoid); + } + if (Cause.hasInterruptsOnly(exit.cause)) { + return Effect.void; + } + return Effect.all([ + Deferred.failCause(serverConfigExit, exit.cause), + Deferred.failCause(configSubscriptionClosed, Cause.map(exit.cause, mapRpcError)), + ]).pipe(Effect.asVoid); + }), + ); + yield* serverConfigSource.pipe(Effect.forkScoped); + const initialConfig = Effect.raceFirst( + Deferred.await(initialConfigDeferred), + Deferred.await(serverConfigExit).pipe( Effect.mapError(mapRpcError), - Effect.withSpan("environment.initialSync"), + Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)), ), + ).pipe(Effect.withSpan("environment.initialSync")); + const serverConfigEvents = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(serverConfigUpdates); + yield* Effect.raceFirst( + Deferred.await(initialConfigDeferred).pipe(Effect.asVoid), + Deferred.await(serverConfigExit), + ); + const snapshot = yield* Ref.get(serverConfigState); + if (Option.isNone(snapshot)) { + return Stream.empty; + } + const updates = Stream.fromSubscription(subscription).pipe( + Stream.filter((buffered) => buffered.revision > snapshot.value.revision), + Stream.mapAccum( + () => snapshot.value.revision, + (revision, buffered) => [ + buffered.revision, + buffered.revision === revision + 1 + ? [buffered.event] + : serverConfigReplayEvents(buffered.replay), + ], + ), + ); + const terminal = Stream.fromEffect(Deferred.await(serverConfigExit)).pipe(Stream.drain); + return Stream.concat( + Stream.fromIterable(serverConfigReplayEvents(snapshot.value)), + Stream.merge(updates, terminal, { haltStrategy: "either" }), + ); + }), + ).pipe( + Stream.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Stream.failCause(cause); + } + // The supervisor keeps the original cause. Shared durable consumers + // need a transport-shaped failure so they wait for its replacement. + return Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: `${connection.label} config subscription failed.`, + cause, + }), + }), + ); + }), ); + const subscribeServerConfig = (input: ServerConfigSubscriptionInput) => + Equal.equals(input, serverConfigInput) + ? serverConfigEvents + : protocolClient[WS_METHODS.subscribeServerConfig](input); const probe = initialConfig.pipe( Effect.flatMap((config) => (config.environment.capabilities.connectionProbe === true - ? client[WS_METHODS.serverProbe]({}) - : client[WS_METHODS.serverGetConfig]({}) + ? protocolClient[WS_METHODS.serverProbe]({}) + : protocolClient[WS_METHODS.serverGetConfig]({}) ).pipe(Effect.mapError(mapRpcError)), ), Effect.asVoid, @@ -145,19 +349,24 @@ const make = Effect.gen(function* () { ); return { - client, + client: protocolClient, initialConfig, + subscribeServerConfig, ready: Deferred.await(connected).pipe( Effect.andThen(initialConfig), Effect.asVoid, Effect.raceFirst(Deferred.await(disconnected)), ), probe, - closed: Deferred.await(disconnected), + closed: Effect.raceFirst( + Deferred.await(disconnected), + Deferred.await(configSubscriptionClosed), + ), } satisfies RpcSession; }); return RpcSessionFactory.of({ connect }); }); -export const layer = Layer.effect(RpcSessionFactory, make); +export const layerWithOptions = (options: RpcSessionOptions) => + Layer.effect(RpcSessionFactory, make(options)); diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 2e0e01f58..6a22b22f1 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -34,6 +34,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 123731aa9..a2cc959d0 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -32,7 +32,6 @@ import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { - applyServerConfigProjection, applyServerWelcomeEvent, makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, @@ -50,6 +49,7 @@ import { waitForDesktopUpdateTarget, runDesktopCommitWithReconnectObserver, } from "./server.ts"; +import { applyServerConfigProjection } from "./serverConfigProjection.ts"; const CONFIG = { availableEditors: [], @@ -81,6 +81,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.succeed(CONFIG), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index c58bba0ec..e1ecf4085 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -46,6 +46,14 @@ import { } from "../rpc/client.ts"; import type { RpcSession } from "../rpc/session.ts"; import { followStreamInEnvironment } from "./runtime.ts"; +import { + applyServerConfigProjection, + type ServerConfigProjection, + withoutEnvironmentThemes, +} from "./serverConfigProjection.ts"; + +// Exported server state includes this type in its inferred public return type. +export type { ServerConfigProjection } from "./serverConfigProjection.ts"; export type ServerUpdateStage = "downloading" | "installing" | "resuming"; @@ -342,119 +350,12 @@ export function resolveServerUpdateProgressResult( return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion })); } -export interface ServerConfigProjection { - readonly config: ServerConfig; - readonly latestEvent: ServerConfigStreamEvent; - readonly source: "cache" | "live"; -} - -/** - * Cached config keeps the provider and model catalog available across reconnects. - * Published themes and usage-limit sources are current machine state, so a - * cache could restore a set the machine no longer reports. Replay sends both - * as separate events. - */ -function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { - if (config.environmentThemes === undefined && config.usageLimitSources === undefined) { - return config; - } - const { environmentThemes: _themes, usageLimitSources: _sources, ...rest } = config; - return rest; -} - -export function applyServerConfigProjection( - current: Option.Option, - event: ServerConfigStreamEvent, -): Option.Option { - switch (event.type) { - case "snapshot": { - // Wire snapshots never contain published themes. Keep the previous set - // until a capable server sends its authoritative theme event. A legacy - // server cannot send a later removal, so a downgrade must clear the set. - const capabilities = event.config.environment.capabilities; - const carriedThemes = - capabilities.environmentThemes === true && Option.isSome(current) - ? current.value.config.environmentThemes - : undefined; - const carriedSources = - capabilities.usageLimitSources === true && Option.isSome(current) - ? current.value.config.usageLimitSources - : undefined; - return Option.some({ - config: { - ...event.config, - ...(carriedThemes === undefined ? {} : { environmentThemes: carriedThemes }), - ...(carriedSources === undefined ? {} : { usageLimitSources: carriedSources }), - }, - latestEvent: event, - source: "live" as const, - }); - } - case "keybindingsUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - keybindings: event.payload.keybindings, - issues: event.payload.issues, - }, - latestEvent: event, - source: "live", - })); - case "providerStatuses": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - providers: event.payload.providers, - }, - latestEvent: event, - source: "live", - })); - case "settingsUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - settings: event.payload.settings, - }, - latestEvent: event, - source: "live", - })); - case "environmentThemesUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, - }, - latestEvent: event, - source: "live", - })); - case "usageLimitSourcesUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - usageLimitSources: event.payload.sources.length > 0 ? event.payload.sources : undefined, - }, - latestEvent: event, - source: "live", - })); - } -} - const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ version: 1, type: "snapshot", config, }); -/** - * Keeps a complete server configuration available during reconnects. Server - * config carries the provider/model catalogue used by task creation, so it is - * useful—and safe—to retain after a transport session ends. - */ -/** - * Published themes live only as long as the machine publishes them, so they - * must not survive in the config cache: a restart or an offline load would - * otherwise hand clients palettes the environment has already dropped. - */ export interface ServerConfigSubscriptionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts new file mode 100644 index 000000000..778839803 --- /dev/null +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -0,0 +1,99 @@ +import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +export interface ServerConfigProjection { + readonly config: ServerConfig; + readonly latestEvent: ServerConfigStreamEvent; + readonly source: "cache" | "live"; +} + +/** + * Cached config keeps the provider and model catalog available across reconnects. + * Published themes and usage-limit sources are current machine state, so a + * cache could restore a set the machine no longer reports. Replay sends both + * as separate events. + */ +export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { + if (config.environmentThemes === undefined && config.usageLimitSources === undefined) { + return config; + } + const { environmentThemes: _themes, usageLimitSources: _sources, ...rest } = config; + return rest; +} + +export function applyServerConfigProjection( + current: Option.Option, + event: ServerConfigStreamEvent, +): Option.Option { + switch (event.type) { + case "snapshot": { + // Wire snapshots never contain published themes. Keep the previous set + // until a capable server sends its authoritative theme event. A legacy + // server cannot send a later removal, so a downgrade must clear the set. + const capabilities = event.config.environment.capabilities; + const carriedThemes = + capabilities.environmentThemes === true && Option.isSome(current) + ? current.value.config.environmentThemes + : undefined; + const carriedSources = + capabilities.usageLimitSources === true && Option.isSome(current) + ? current.value.config.usageLimitSources + : undefined; + return Option.some({ + config: { + ...event.config, + ...(carriedThemes === undefined ? {} : { environmentThemes: carriedThemes }), + ...(carriedSources === undefined ? {} : { usageLimitSources: carriedSources }), + }, + latestEvent: event, + source: "live" as const, + }); + } + case "keybindingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + keybindings: event.payload.keybindings, + issues: event.payload.issues, + }, + latestEvent: event, + source: "live", + })); + case "providerStatuses": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + providers: event.payload.providers, + }, + latestEvent: event, + source: "live", + })); + case "settingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + settings: event.payload.settings, + }, + latestEvent: event, + source: "live", + })); + case "environmentThemesUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, + }, + latestEvent: event, + source: "live", + })); + case "usageLimitSourcesUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + usageLimitSources: event.payload.sources.length > 0 ? event.payload.sources : undefined, + }, + latestEvent: event, + source: "live", + })); + } +} diff --git a/packages/client-runtime/src/state/serverUsage.test.ts b/packages/client-runtime/src/state/serverUsage.test.ts index bb961d95d..9cf2667d8 100644 --- a/packages/client-runtime/src/state/serverUsage.test.ts +++ b/packages/client-runtime/src/state/serverUsage.test.ts @@ -101,6 +101,7 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( const session: RpcSession = { client, initialConfig: Effect.succeed(CONFIG), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 4899095cf..0d933c39f 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -52,6 +52,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index 393be8e32..33c566bf8 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -50,6 +50,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 62c7cd257..27229a7af 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -129,6 +129,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? threadResumeCompletionMarker: true, threadSnapshotPagination: true, } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-failures.test.ts b/packages/client-runtime/src/state/threads-failures.test.ts index 73e16909c..df590ac9b 100644 --- a/packages/client-runtime/src/state/threads-failures.test.ts +++ b/packages/client-runtime/src/state/threads-failures.test.ts @@ -106,6 +106,7 @@ const makeHarness = Effect.fn("TestThreadFailures.makeHarness")(function* (optio const session: RpcSession = { client, initialConfig: Effect.succeed({ threadResumeCompletionMarker: true } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts index 62cad18f8..2cede4f5b 100644 --- a/packages/client-runtime/src/state/threads-pagination.test.ts +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -156,6 +156,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt initialConfig: Effect.succeed({ threadSnapshotPagination: options?.paginationCapability !== false, } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 7fbfc159a..619b5f434 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -116,6 +116,7 @@ function testSession( ? ({ threadResumeCompletionMarker: true } as never) : ({} as never), ), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 0a6264c62..d7a4692fc 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -86,6 +86,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index b936246dc..905972975 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -84,6 +84,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 93ac435fa..c4176b33c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -98,7 +98,7 @@ import type { import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; -import { type ClientSettings, SnapShotShortcut } from "./settings.ts"; +import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts"; import type { EditorId } from "./editor.ts"; import type { SourceControlCloneRepositoryInput, @@ -127,6 +127,10 @@ export interface ContextMenuItem { children?: readonly ContextMenuItem[]; } +export type QuitShortcutHintEvent = + | { readonly state: "down"; readonly mode: Exclude } + | { readonly state: "up" }; + export interface ContextMenuItemSchemaType { readonly id: string; readonly label: string; @@ -1301,11 +1305,10 @@ export interface DesktopBridge { onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent?: (listener: (event: DesktopSnapShotEvent) => 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. + * Quit-confirmation hint pushes. Optional: older desktop builds never emit + * them. */ - onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; + onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index dfa836fe0..456a89d05 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -167,6 +167,42 @@ describe("ClientSettings retired status motion", () => { }); }); +describe("ClientSettings quit confirmation", () => { + it("defaults to hold", () => { + expect(decodeClientSettings({}).confirmQuit).toBe("hold"); + }); + + it.each(["direct", "hold", "double-click"] as const)("accepts the %s mode", (mode) => { + expect(decodeClientSettings({ confirmQuit: mode }).confirmQuit).toBe(mode); + expect(decodeClientSettingsPatch({ confirmQuit: mode }).confirmQuit).toBe(mode); + }); + + it.each([ + [true, "hold"], + [false, "direct"], + ] as const)("migrates the legacy %s value to %s", (legacyValue, mode) => { + const settings = decodeClientSettings({ confirmQuit: legacyValue }); + + expect(settings.confirmQuit).toBe(mode); + expect(encodeClientSettings(settings).confirmQuit).toBe(mode); + }); + + it("rejects legacy booleans at the patch boundary", () => { + expect(() => decodeClientSettingsPatch({ confirmQuit: true })).toThrow(); + }); + + it("reads a stored mode or legacy boolean without leaving it unread", () => { + expect(decodeStoredClientSettings({ confirmQuit: "double-click" })).toEqual({ + settings: { ...DEFAULT_CLIENT_SETTINGS, confirmQuit: "double-click" }, + unreadValues: {}, + }); + expect(decodeStoredClientSettings({ confirmQuit: false })).toEqual({ + settings: { ...DEFAULT_CLIENT_SETTINGS, confirmQuit: "direct" }, + unreadValues: {}, + }); + }); +}); + describe("stored client settings", () => { it("returns null for a document that is not a settings object", () => { expect(decodeStoredClientSettings("settings")).toBeNull(); @@ -176,29 +212,29 @@ describe("stored client settings", () => { it("defaults only the values it cannot decode and retains them until they change", () => { const stored = decodeStoredClientSettings({ - confirmQuit: "hold", + diffLayout: "unified", fontSizeCode: "large", timestampFormat: "12-hour", }); expect(stored).toEqual({ settings: { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" }, - unreadValues: { confirmQuit: "hold", fontSizeCode: "large" }, + unreadValues: { diffLayout: "unified", fontSizeCode: "large" }, }); const unchanged = retainUnreadClientSettings({ ...stored!.settings, wordWrap: false }, stored); expect(encodeStoredClientSettings(unchanged)).toMatchObject({ - confirmQuit: "hold", + diffLayout: "unified", fontSizeCode: "large", timestampFormat: "12-hour", wordWrap: false, }); const changed = retainUnreadClientSettings( - { ...unchanged.settings, confirmQuit: false }, + { ...unchanged.settings, diffLayout: "split" }, unchanged, ); expect(changed.unreadValues).toEqual({ fontSizeCode: "large" }); expect(encodeStoredClientSettings(changed)).toMatchObject({ - confirmQuit: false, + diffLayout: "split", fontSizeCode: "large", }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f41dd69f0..cbab5deac 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -151,6 +151,22 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); +export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; +const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; + +const LegacyConfirmQuit = Schema.Boolean.pipe( + Schema.decodeTo( + QuitConfirmationMode, + SchemaTransformation.transform({ + decode: (confirmQuit): QuitConfirmationMode => (confirmQuit ? "hold" : "direct"), + encode: (mode) => mode === "hold", + }), + ), +); + +const QuitConfirmationModeSetting = Schema.Union([QuitConfirmationMode, LegacyConfirmQuit]); + export const SnapShotKeyChord = KeybindingShortcut.check( Schema.makeFilter( (shortcut) => @@ -304,9 +320,11 @@ export const ClientSettingsSchema = Schema.Struct({ browserDefaultProfileId: BrowserProfileId.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_PROFILE_ID)), ), - // 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))), + // Desktop-only. Boolean values from older settings files decode to their + // equivalent mode and encode back as the canonical string value. + confirmQuit: QuitConfirmationModeSetting.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)), + ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1488,7 +1506,7 @@ export const ClientSettingsPatch = Schema.Struct({ browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)), browserDefaultProfileId: Schema.optionalKey(BrowserProfileId), - confirmQuit: Schema.optionalKey(Schema.Boolean), + confirmQuit: Schema.optionalKey(QuitConfirmationMode), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),