diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 7b8b8c4ea1..b8e54ee743 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -15,6 +15,57 @@ Two standing sections outlive any single batch and must be read on every review: ## Review batches +## 2026-09-06 — approved Pylon cleanup adaptations (partial range) + +The maintainer approved broad compatible adoption and routine Pylon adaptations. This batch adopts 36 source commits after reading their complete patches, PR descriptions, review context, and Pylon callers. The bounded upstream head is `223ff4490f764a74ff911589e97b9bbcd595fee8`; the full review cursor remains unchanged because the complete 614-commit range is not yet classified. + +Outcome: integrated on `upstream/2026-09-06-cleanup-pylon-adaptations`, based on `origin/pylon` at `72db9134920efdf4209a006e409f6117d839066c`. Verification: 1,125 focused tests across 36 files passed, including the corrected pricing adaptation; all seven affected package typechecks (shared, contracts, client-runtime, server, web, mobile, desktop), targeted lint, and formatting passed. After rebasing onto the merged workspace-skill changes, 89 focused provider tests and the server typecheck passed, including the newly adopted Grok discovery cleanup. No layout, motion, protocol, persistence version, or native dependency changes are introduced. + +Pylon adaptations preserve Prime and other provider choices, account-scoped capacity retention, Cursor discovery caching, held-send recovery and explicit model routing, preview URL focus, and product identity assertions. Composer cleanup keeps the real reload/annotation recovery fix from Pylon #330. Removed HSL theme helpers have no production callers; the editor already uses the vivid OKLCH generator, whose semantic contrast tests remain. Obsolete diff helpers are removed without importing upstream's separate long-line/performance changes. Real worker tests in Pylon remain. Pricing keeps the same bare-model lookup by using its existing normalized key directly. + +| Upstream concern | Source commit | Decision | +| ----------------------------------------------------------------------------- | ------------------------------------------ | ----------------------------------- | +| test(mobile): keep composer selection helper private (#9953) | `a399473c30e6b451ec6348e52cbf9b4c4b07e505` | Adopted with Pylon-first adaptation | +| test(contracts): keep driver default lookup private (#9968) | `dfc70a3294641a2acb021766a759ac4c79665944` | Adopted with Pylon-first adaptation | +| refactor(mobile): remove unused layout calculations (#9974) | `eda0cec92398a4097958b55accaa59fdd178a977` | Adopted with Pylon-first adaptation | +| test(server): remove provider equality wrapper fixture (#9979) | `2fc630c9e85321b7b3963372fd316877a7581639` | Adopted with Pylon-first adaptation | +| test(server): assert the dispatched welcome thread model (#9980) | `1550f1b742138df8b31b24e34ddea58c80afb3cc` | Adopted with Pylon-first adaptation | +| refactor(desktop): remove unused keyring remediation text (#9981) | `14da634577773d291c494635c1a4bcdb0e70f9ee` | Adopted with Pylon-first adaptation | +| refactor(desktop): remove test-only Electron error predicates (#9982) | `a9caf7b7089afb6f1b3b888ea404b75b8f9e88e1` | Adopted with Pylon-first adaptation | +| test(server): remove authorization prompt snapshots (#9985) | `371e32392570fdc0d2309e9f92490bbd2c6b3f02` | Adopted with Pylon-first adaptation | +| test(server): remove provider label identity assertion (#9987) | `9867eb12396439f58e03e3548e053d966f0f64a6` | Adopted with Pylon-first adaptation | +| test(server): consolidate agent activity opt-in coverage (#9988) | `94d1fa7ff268e88476a341eb73f9f032e2af6a21` | Adopted with Pylon-first adaptation | +| refactor(shared): remove retired PATH capture parser (#9991) | `da1bebbb11701b58ebdde5ee019f3aeef3d42b27` | Adopted with Pylon-first adaptation | +| refactor(client-runtime): remove unused subagent selectors (#9992) | `3bcde91f8215ec24eeffdd4abdee8fcb8a5e94f9` | Adopted with Pylon-first adaptation | +| refactor(web): remove obsolete cloud listing helpers (#9995) | `bf1c1b09756a56a0600faf52561f0fb4ae94e355` | Adopted with Pylon-first adaptation | +| test(server): cover raw OpenCode deltas through the adapter (#9977) | `3fedf52467dd8d2734a7aca4c6c7da421c12474f` | Adopted with Pylon-first adaptation | +| refactor(server): remove test-only pricing normalizer (#10017) | `270e021f2a7ed34e3d81d1cdc9f1ca0c0ac85df4` | Adopted with Pylon-first adaptation | +| refactor(web): test environment-scoped draft promotion (#10010) | `82c2b7ffb4d450572f5baf9a70693f1e25cfdfed` | Adopted with Pylon-first adaptation | +| refactor(server): remove unused startup heartbeat launcher (#10030) | `f5d9d12026d98b2b4b975f1944e7503d6c2c9eb3` | Adopted with Pylon-first adaptation | +| refactor(mobile): test composer persistence through the live decoder (#10033) | `83a2897ed6c400b3679a37920e964d829094bd43` | Adopted with Pylon-first adaptation | +| refactor(web): remove unused sidebar selectors (#10034) | `5cb696595cd66bb00e03fa5f91d9cd4273949f5b` | Adopted with Pylon-first adaptation | +| refactor(server): keep Cursor fallback models private (#10038) | `ad3721eb5bec31066d162916f700107aab5e125b` | Adopted with Pylon-first adaptation | +| refactor(mobile): keep appearance calculations private (#10043) | `5e828bf3de1c86d85afdd91a02cc4ca1a38d4aef` | Adopted with Pylon-first adaptation | +| test(server): exercise Codex prompts through public assembly (#10045) | `df370c31d26afc72ace55263e746ba4386dada25` | Adopted with Pylon-first adaptation | +| refactor(shared): remove unused elapsed-time adapter (#10046) | `62e74cdd9a0aa4df5fc612d94193da9610129dc9` | Adopted with Pylon-first adaptation | +| refactor(web): remove obsolete HSL theme generator (#10061) | `80f775a42c448290c102e572fc889e4e4a1dd546` | Adopted with Pylon-first adaptation | +| refactor(mobile): remove obsolete native diff token stream (#10062) | `d524eb96936090bd2b8a96449784155ad4af9591` | Adopted with Pylon-first adaptation | +| test(server): remove title prompt editorial snapshots (#10063) | `bc24d98d1630104722abe6a4d839365d24e2aa27` | Adopted with Pylon-first adaptation | +| refactor(web): observe preview tests through the live registry (#10064) | `43700b83e73fca3af7b63769876c409b5c6f5a3f` | Adopted with Pylon-first adaptation | +| test(server): remove keybinding default assignment snapshot (#10065) | `fedf84ac7922e11be0971ac50817d7c4199b8e89` | Adopted with Pylon-first adaptation | +| refactor(mobile): remove obsolete whole-file review highlighters (#10067) | `4db2c542a8c5e6b6756fac08615e30b220b71a2f` | Adopted with Pylon-first adaptation | +| refactor(web): test live keybinding resolvers directly (#10069) | `009c13fade9dde2fe7eed068c72c579172f12e60` | Adopted with Pylon-first adaptation | +| test(web): remove mocked diff view prop snapshot (#10073) | `38812c101e562c4f8d9bea7b1a592a4b02954a39` | Adopted with Pylon-first adaptation | +| test(web): remove mocked annotation options snapshot (#10074) | `19ea3c5beb4cd93dd19f51f3e7c6d760357b41df` | Adopted with Pylon-first adaptation | +| refactor(mobile): remove unused cloud pending-status mapper (#10071) | `b92a8122876458a67cc225f75c1d31bd3e33dc30` | Adopted with Pylon-first adaptation | +| refactor(web): remove unused model picker hint helpers (#10072) | `ab0933a41ae3a69632ea68809700a040bde26c22` | Adopted with Pylon-first adaptation | +| test(web): drop provider banner styling assertions (#10148) | `47e250a842ee254cd2230949f8575093da6a7adf` | Adopted with Pylon-first adaptation | +| test(server): cover Grok skill parsing through discovery (#10070) | `25cbcd62d9750d3e8903fd752078e66c7943ecdb` | Adopted after Pylon #310 merged | + +Reviewed exceptions: #9959, #9976, and #9983 still have live Pylon callers and are deferred in DEF-13 through DEF-15. #9986 is skipped: Pylon retains explicit desktop product-identity regression coverage. #9996, #9997, #10005, #10028, and #10059 target helpers/tests already absent from Pylon and require no port; these are not counted among the 36 adoptions. DEF-16 was re-evaluated after Pylon #310 merged: #10070 now applies cleanly, keeps the live typed discovery failures, and is adopted here. DEF-16 is retired and must not be reused. + +DEF-7/DEF-8 and WATCH-1 through WATCH-3 retain the decisions recorded in the preceding batch. This cleanup does not change their revisit conditions or owner issues. DEF-11/DEF-12 remain deferred because their Pylon callers still exist. + ## 2026-09-06 — approved unused-helper cleanup (partial range) The maintainer authorized compatible upstream adoption in larger batches and routine Pylon adaptations. This batch adopts 49 reviewed commits from the range ending at `223ff4490f764a74ff911589e97b9bbcd595fee8`. The full cursor stays at `beae2147a9487ec47ac992319f2216914b4cb62d`: the 614-commit range has not all been reviewed. @@ -3641,13 +3692,16 @@ Every review must read this register before reporting new candidates, re-evaluate each `Revisit when` against the current upstream head, and report the outcome. See Phase 2.5 of the `review-t3-upstream` skill. -| ID | Upstream | Deferred on | Revisit when | Why deferred | -| ----- | ----------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| DEF-7 | `33b650a5b` / `#8243` — anonymous macOS preview DMG downloads | 2026-08-27 | Either premise changes. (a) Pylon decides to distribute unsigned preview builds publicly — an external tester programme, or a contributor who cannot use `gh run download`. (b) Upstream lifts the same-repo restriction or drops the `contents: write` publish job: check with `git log --oneline ..t3code-upstream/main -- .github/workflows/desktop-macos-preview.yml` and read any hit touching the `if:` guards or job permissions. Not before 2026-11-01 — neither premise moves on a weekly timescale, and this should not be re-litigated every review. | Adopted as `#111`, then reverted by `#113` the same day. The port was sound and its four review findings were fixed; the feature does not earn its cost here. Fork PRs cannot trigger it, `gh run download` covers the headless case, and Pylon Nightly covers merged builds — leaving one maintainer-only use against 210 lines of race-prone YAML, a `contents: write` token on a PR-triggered workflow, a publicly hosted unsigned binary, and a Releases widget on a repository that otherwise has none. If revived, start from `#111` plus the fixes in `9d112329e`, not from upstream. | -| DEF-8 | `1f8ed54ad` / `#8694` — mobile dev-client reload and Metro startup cost | 2026-08-29 | `#171` (Expo SDK 57) has merged: `git log --oneline origin/pylon --grep "Expo SDK 57"` returns a commit. Then split it: the dev-loop half (`--clear` removal, dev `appVersion` runtime policy, uniwind 1.11 plus its patch, hot-swappable atom runtime, fast-refresh hooks, docs) and the product half (ControlPill long-press with the `@react-native-menu/menu` native patch, `home-thread-navigation`, `remote-environment-projections`, and the `client-runtime` `connection/registry.ts` scope change that also affects web). Drop upstream's `AGENTS.md` and `test-t3-mobile` skill hunks; keep the `pylon-code-*` schemes in `package.json`. Not before 2026-09-05. | Its lockfile was generated on Expo 57 and RN 0.86 and only verified there; on `pylon` today it conflicts on `pnpm-workspace.yaml` (4.25.2 versus the 4.26.2 screens patch), `apps/mobile/package.json` scheme identity, the lock, and Pylon-owned docs. Both halves need a native rebuild. Reviewed 2026-08-29; not a fit to land ahead of the SDK upgrade it was built on. | +| ID | Upstream | Deferred on | Revisit when | Why deferred | +| ------ | ----------------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DEF-7 | `33b650a5b` / `#8243` — anonymous macOS preview DMG downloads | 2026-08-27 | Either premise changes. (a) Pylon decides to distribute unsigned preview builds publicly — an external tester programme, or a contributor who cannot use `gh run download`. (b) Upstream lifts the same-repo restriction or drops the `contents: write` publish job: check with `git log --oneline ..t3code-upstream/main -- .github/workflows/desktop-macos-preview.yml` and read any hit touching the `if:` guards or job permissions. Not before 2026-11-01 — neither premise moves on a weekly timescale, and this should not be re-litigated every review. | Adopted as `#111`, then reverted by `#113` the same day. The port was sound and its four review findings were fixed; the feature does not earn its cost here. Fork PRs cannot trigger it, `gh run download` covers the headless case, and Pylon Nightly covers merged builds — leaving one maintainer-only use against 210 lines of race-prone YAML, a `contents: write` token on a PR-triggered workflow, a publicly hosted unsigned binary, and a Releases widget on a repository that otherwise has none. If revived, start from `#111` plus the fixes in `9d112329e`, not from upstream. | +| DEF-8 | `1f8ed54ad` / `#8694` — mobile dev-client reload and Metro startup cost | 2026-08-29 | `#171` (Expo SDK 57) has merged: `git log --oneline origin/pylon --grep "Expo SDK 57"` returns a commit. Then split it: the dev-loop half (`--clear` removal, dev `appVersion` runtime policy, uniwind 1.11 plus its patch, hot-swappable atom runtime, fast-refresh hooks, docs) and the product half (ControlPill long-press with the `@react-native-menu/menu` native patch, `home-thread-navigation`, `remote-environment-projections`, and the `client-runtime` `connection/registry.ts` scope change that also affects web). Drop upstream's `AGENTS.md` and `test-t3-mobile` skill hunks; keep the `pylon-code-*` schemes in `package.json`. Not before 2026-09-05. | Its lockfile was generated on Expo 57 and RN 0.86 and only verified there; on `pylon` today it conflicts on `pnpm-workspace.yaml` (4.25.2 versus the 4.26.2 screens patch), `apps/mobile/package.json` scheme identity, the lock, and Pylon-owned docs. Both halves need a native rebuild. Reviewed 2026-08-29; not a fit to land ahead of the SDK upgrade it was built on. | +| DEF-11 | `eae770b125553275e1ccfe4aebd88df28e818aed` / #9956 — remove changed-files preview helpers | 2026-09-06 | Revisit when #9821 or an equivalent persistent changed-files tree is adopted. Check imports of `changedFilesPresentation` in `apps/web/src/components/ChatView.tsx` and `apps/web/src/components/chat/ChangedFilesTree.tsx`; delete only after no live caller remains. | Pylon still uses these helpers for its current changed-files UI. Removing them now breaks live callers. | +| DEF-12 | `aca2afc0b5400e4cf88c42d59b214240833ed9cd` / #9989 — remove loopback preview predicate | 2026-09-06 | Revisit when #9339 or an equivalent terminal-link target selector replaces the call in `openTerminalLinkInPreview`. Check `rg isPreviewableUrl apps packages` before removal. | Pylon still uses the predicate to decide which terminal URLs open in the preview. | -| DEF-11 | `eae770b125553275e1ccfe4aebd88df28e818aed` / #9956 — remove changed-files preview helpers | 2026-09-06 | Revisit when #9821 or an equivalent persistent changed-files tree is adopted. Check imports of `changedFilesPresentation` in `apps/web/src/components/ChatView.tsx` and `apps/web/src/components/chat/ChangedFilesTree.tsx`; delete only after no live caller remains. | Pylon still uses these helpers for its current changed-files UI. Removing them now breaks live callers. | -| DEF-12 | `aca2afc0b5400e4cf88c42d59b214240833ed9cd` / #9989 — remove loopback preview predicate | 2026-09-06 | Revisit when #9339 or an equivalent terminal-link target selector replaces the call in `openTerminalLinkInPreview`. Check `rg isPreviewableUrl apps packages` before removal. | Pylon still uses the predicate to decide which terminal URLs open in the preview. | +| DEF-13 | `4a42fc62e15018973b0088667d037b2a35c4c261` / #9959 — remove web UI and provider helpers | 2026-09-06 | Revisit after ColorSelector, PROVIDER_OPTIONS, and getProviderInteractionModeToggle have no live Pylon callers; check ProviderAccentColorPicker, providerIconUtils, and ChatComposer. | These helpers still implement Pylon provider customization and interaction controls. | +| DEF-14 | `044a6e168ad1eb213c2c7c277034e65c638cc937` / #9976 — remove conventional file-position predicate | 2026-09-06 | Revisit only after web and mobile markdown link parsing no longer imports isConventionalFilePosition. | Both Pylon markdown clients still use this parser. | +| DEF-15 | `ba873b8181c3741603a365e75785457064a52ffd` / #9983 — remove pull-request opener helper | 2026-09-06 | Revisit after GitActionsControl no longer calls openPullRequestLink or a reviewed replacement handles its connection modes. | Pylon still uses this action to open pull requests. | ## Upstream watch list diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 3acaf71545..2ed5a1f2f9 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -43,7 +43,6 @@ describe("ElectronDialog", () => { ); assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); - assert.isTrue(ElectronDialog.isElectronDialogError(error)); assert.strictEqual(error.ownerWindowId, 7); assert.strictEqual(error.defaultPath, "/workspace"); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 4300d9ab0d..30ca73a5e1 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -73,7 +73,6 @@ export const ElectronDialogError = Schema.Union([ ElectronDialogShowErrorBoxError, ]); export type ElectronDialogError = typeof ElectronDialogError.Type; -export const isElectronDialogError = Schema.is(ElectronDialogError); export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4b81943eff..b4028930af 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -64,7 +64,6 @@ describe("ElectronTheme", () => { const error = yield* Effect.flip(electronTheme.setSource("dark")); assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); - assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); assert.strictEqual(error.source, "dark"); assert.strictEqual(error.cause, cause); assert.include(error.message, "dark"); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ef47e3d095..24b2d856b9 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -19,8 +19,6 @@ export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass { const error = yield* updater.checkForUpdates.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "beta"); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); @@ -89,7 +88,6 @@ describe("ElectronUpdater", () => { const error = yield* updater.downloadUpdate.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, cause); assert.equal( @@ -126,7 +124,6 @@ describe("ElectronUpdater", () => { .pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "alpha"); assert.equal(error.isSilent, true); assert.equal(error.isForceRunAfter, false); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 4157d29a9d..8e044de65a 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -54,7 +54,6 @@ export const ElectronUpdaterError = Schema.Union([ ElectronUpdaterQuitAndInstallError, ]); export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; -export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); export class ElectronUpdater extends Context.Service< ElectronUpdater, diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index a013ab2464..b13763c07a 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -79,7 +79,6 @@ describe("ElectronWindow", () => { const error = yield* electronWindow.create(options).pipe(Effect.flip); assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); - assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); assert.deepEqual(error.options, { title: "Pylon", width: 1100, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 5f6a9d3428..9234399191 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -58,8 +58,6 @@ export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowOperationError", { diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index a917902007..5827e38e40 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeLinuxPasswordStorePreference, resolveLinuxPasswordStoreSwitch, - resolveLinuxSecretStorageUnavailableMessage, } from "./linuxSecretStorage.ts"; const autoSwitch = (env: NodeJS.ProcessEnv) => @@ -124,80 +123,4 @@ describe("linuxSecretStorage", () => { }), ).toBe("gnome-libsecret"); }); - - it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit libsecret selection over KDE desktop heuristics", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "gnome-libsecret", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet6", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet", - selectedBackend: "gnome-libsecret", - env: {}, - }), - ).toContain("KWallet"); - }); - - it("uses KWallet remediation wording for KDE-looking sessions", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "kwallet6", - env: {}, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { DESKTOP_SESSION: "plasmawayland" }, - }), - ).toContain("KWallet"); - // A desktop name outranks a bare KDE marker when choosing the wording. - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, - }), - ).toContain("GNOME Keyring"); - }); }); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index dcaf1a1dcb..3aa7a440d1 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -25,9 +25,6 @@ const ELECTRON_KDE_DESKTOP = "KDE"; // Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); -const KDE_NAME_PREFIXES = ["kde", "plasma"]; -const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); - export function normalizeLinuxPasswordStorePreference( value: unknown, ): LinuxPasswordStorePreference { @@ -77,102 +74,6 @@ function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { return false; } -export function resolveLinuxSecretStorageUnavailableMessage(input: { - readonly configuredPreference: LinuxPasswordStorePreference; - readonly selectedBackend: string | null; - readonly env: NodeJS.ProcessEnv; -}): string { - if (input.configuredPreference === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - input.configuredPreference === "kwallet" || - input.configuredPreference === "kwallet5" || - input.configuredPreference === "kwallet6" - ) { - return getKWalletRemediationMessage(); - } - - const backend = normalizeSelectedStorageBackend(input.selectedBackend); - if (backend === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - backend === "kwallet" || - backend === "kwallet5" || - backend === "kwallet6" || - looksLikeKdeSession(input.env) - ) { - return getKWalletRemediationMessage(); - } - - return getGnomeKeyringRemediationMessage(); -} - -function getGnomeKeyringRemediationMessage(): string { - return "Pylon could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart Pylon."; -} - -function getKWalletRemediationMessage(): string { - return "Pylon could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart Pylon."; -} - -// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It -// never decides which backend to select, so a loose match costs a user slightly wrong instructions -// rather than an unprotected credential store. -function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { - const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); - if (currentDesktopNames.length > 0) { - return currentDesktopNames.some(isKdeDesktopName); - } - - const legacyNames = legacyDesktopNames(env); - if (legacyNames.length > 0) { - return legacyNames.some(isKdeDesktopName); - } - - return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); -} - -function isKdeDesktopName(name: string): boolean { - return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); -} - -function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { - return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function nonEmptyDesktopNames(value: string | undefined): string[] { - return splitDesktopNameList(value).flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function isSet(value: string | undefined): boolean { - return Boolean(value?.trim()); -} - -function isAffirmativeFlag(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; -} - function splitDesktopNameList(value: string | undefined): string[] { return value?.split(":") ?? []; } - -function normalizeDesktopName(value: string | undefined): string | null { - const normalized = value?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeSelectedStorageBackend(value: string | null): string | null { - const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); - return normalized && normalized.length > 0 ? normalized : null; -} diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index ad5d5cb246..29a473f7d1 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -11,7 +11,6 @@ import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; import { MobileStorage } from "../../persistence/mobile-storage"; import { - cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, linkEnvironmentToCloudWithPreference, connectCloudEnvironment, @@ -186,16 +185,6 @@ describe("mobile cloud link environment client", () => { loadPreferences.mockClear(); }); - it("makes linked environments visible while their status is still loading", () => { - expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([ - { - environment: { environmentId: "env-1", label: "Desktop" }, - status: null, - statusError: "Checking status...", - }, - ]); - }); - it.effect("decodes relay environment list responses before returning records", () => Effect.gen(function* () { vi.stubGlobal( diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index be63cd5877..b8dc8f878e 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -397,16 +397,6 @@ export function getCloudEnvironmentStatus(input: { }); } -export function cloudEnvironmentsPendingStatus( - environments: ReadonlyArray, -): ReadonlyArray { - return environments.map((environment) => ({ - environment, - status: null, - statusError: "Checking status...", - })); -} - export function loadCloudEnvironmentStatuses(input: { readonly clerkToken: string; readonly environments: ReadonlyArray; diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 14158e61c7..db0d4bab3a 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -61,26 +61,6 @@ interface IndexedNativeReviewDiffLineRow { readonly rowIndex: number; } -export interface NativeReviewDiffTokenChunk { - readonly chunkIndex: number; - readonly fileId: string; - readonly filePath: string; - readonly language: NativeReviewDiffLanguage; - readonly lineCount: number; - readonly durationMs: number; - readonly tokensByRowId: Record>; -} - -export interface StreamNativeReviewDiffTokenInput { - readonly rows: ReadonlyArray; - readonly files: ReadonlyArray; - readonly scheme: NativeReviewDiffHighlightScheme; - readonly engine?: NativeReviewDiffHighlightEngine; - readonly chunkSize?: number; - readonly signal?: AbortSignal; - readonly onChunk: (chunk: NativeReviewDiffTokenChunk) => void; -} - export interface HighlightNativeReviewDiffVisibleRowsInput { readonly rows: ReadonlyArray; readonly files: ReadonlyArray; @@ -94,7 +74,6 @@ export interface HighlightNativeReviewDiffVisibleRowsInput { readonly signal?: AbortSignal; } -const NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE = 500; const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160; const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360; @@ -210,10 +189,6 @@ const NATIVE_REVIEW_DIFF_LANGUAGES = [ let nativeHighlighterPromise: Promise | null = null; let javascriptHighlighterPromise: Promise | null = null; -function waitForNextFrame(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - function normalizeTokens( tokenLines: ReadonlyArray>, ): ReadonlyArray> { @@ -365,20 +340,6 @@ function canShareGrammarContext( ); } -function groupLineRowsByFileId(rows: ReadonlyArray) { - const rowsByFileId = new Map(); - for (const row of rows) { - if (!isHighlightableLineRow(row)) { - continue; - } - - const fileRows = rowsByFileId.get(row.fileId) ?? []; - fileRows.push(row); - rowsByFileId.set(row.fileId, fileRows); - } - return rowsByFileId; -} - function createFileMap(files: ReadonlyArray) { return new Map(files.map((file) => [file.id, file])); } @@ -484,47 +445,3 @@ export async function highlightNativeReviewDiffVisibleRows( durationMs: Math.round(performance.now() - startedAt), }; } - -export async function streamNativeReviewDiffTokens( - input: StreamNativeReviewDiffTokenInput, -): Promise { - const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native"); - const rowsByFileId = groupLineRowsByFileId(input.rows); - const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme]; - const chunkSize = input.chunkSize ?? NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE; - let chunkIndex = 0; - - for (const file of input.files) { - const fileRows = rowsByFileId.get(file.id) ?? []; - for (let startIndex = 0; startIndex < fileRows.length; startIndex += chunkSize) { - if (input.signal?.aborted) { - return highlighter.engine; - } - - const startedAt = performance.now(); - const chunkRows = fileRows.slice(startIndex, startIndex + chunkSize); - const code = chunkRows.map((row) => row.content).join("\n"); - const tokenLines = highlighter.tokenize(code, { lang: file.language, theme }); - const tokensByRowId: Record> = {}; - - chunkRows.forEach((row, rowIndex) => { - tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); - }); - - input.onChunk({ - chunkIndex, - fileId: file.id, - filePath: file.path, - language: file.language, - lineCount: chunkRows.length, - durationMs: Math.round(performance.now() - startedAt), - tokensByRowId, - }); - - chunkIndex += 1; - await waitForNextFrame(); - } - } - - return highlighter.engine; -} diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index 0bc6469426..45af97ffed 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -1,122 +1,88 @@ import { describe, expect, it } from "vite-plus/test"; -import type { ReviewRenderableFile } from "./reviewModel"; -import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; +import type { ReviewRenderableLineRow } from "./reviewModel"; +import { + highlightCodeSnippet, + highlightReviewSelectedLines, + highlightSourceFile, +} from "./shikiReviewHighlighter"; -function makeRenderableFile( - input: Partial & Pick, -): ReviewRenderableFile { - return { - id: input.path, - cacheKey: input.path, - previousPath: null, - changeType: "new", - additions: 0, - deletions: 0, - languageHint: null, - additionLines: [], - deletionLines: [], - rows: [], - ...input, - }; -} +describe("highlightSourceFile", () => { + it("preserves one highlighted token row per source line without trailing newlines", async () => { + const lines = [ + 'const items = ["a"];', + 'expect(items).toEqual(["a"]);', + "const next = items.map((item) => item.toUpperCase());", + 'expect(next).toContain("A");', + ]; -describe("highlightReviewFile", () => { - it("preserves one highlighted token row per diff line even without trailing newlines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example.txt", - additionLines: [ - 'const items = ["a"];', - 'expect(items).toEqual(["a"]);', - "const next = items.map((item) => item.toUpperCase());", - 'expect(next).toContain("A");', - ], + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example.ts", + contents: lines.join("\n"), + theme: "light", }); - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(file.additionLines.length); - expect(highlighted.additionLines[0]?.map((token) => token.content).join("")).toBe( - file.additionLines[0], - ); - expect(highlighted.additionLines[1]?.map((token) => token.content).join("")).toBe( - file.additionLines[1], - ); - expect(highlighted.additionLines[2]?.map((token) => token.content).join("")).toBe( - file.additionLines[2], + expect(highlighted.map((tokens) => tokens.map((token) => token.content).join(""))).toEqual( + lines, ); - expect(highlighted.additionLines[3]?.map((token) => token.content).join("")).toBe( - file.additionLines[3], - ); - }); - - it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example-inline-diff.txt", - additionLines: ["const after = 2;"], - deletionLines: ["const before = 1;"], - rows: [ - { - kind: "line", - id: "delete-1", - change: "delete", - oldLineNumber: 1, - newLineNumber: null, - content: "const before = 1;", - additionTokenIndex: null, - deletionTokenIndex: 0, - comparison: { change: "add", tokenIndex: 0 }, - }, - { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: { change: "delete", tokenIndex: 0 }, - }, - ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); - expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); }); it("falls back to plain tokens for very long lines", async () => { const longLine = `const value = "${"a".repeat(1_100)}";`; - const file = makeRenderableFile({ - path: "apps/mobile/src/example-long-line.txt", - additionLines: [longLine], - rows: [ + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example-long-line.ts", + contents: longLine, + theme: "light", + }); + + expect(highlighted).toEqual([ + [ { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, content: longLine, - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, + color: null, + fontStyle: null, }, ], - }); - - const highlighted = await highlightReviewFile(file, "light"); + ]); + }); +}); - expect(highlighted.additionLines).toHaveLength(1); - expect(highlighted.additionLines[0]).toEqual([ +describe("highlightReviewSelectedLines", () => { + it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { + const lines: ReviewRenderableLineRow[] = [ { - content: longLine, - color: null, - fontStyle: null, + kind: "line", + id: "delete-1", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + content: "const before = 1;", + additionTokenIndex: null, + deletionTokenIndex: 0, + comparison: { change: "add", tokenIndex: 0 }, }, - ]); + { + kind: "line", + id: "add-1", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + content: "const after = 2;", + additionTokenIndex: 0, + deletionTokenIndex: null, + comparison: { change: "delete", tokenIndex: 0 }, + }, + ]; + + const highlighted = await highlightReviewSelectedLines({ + filePath: "apps/mobile/src/example-inline-diff.txt", + lines, + theme: "light", + }); + + expect(highlighted["delete-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + expect(highlighted["add-1"]?.some((token) => token.diffHighlight === true)).toBe(true); }); }); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index c684a66864..8fa7f69a43 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -17,7 +17,7 @@ import { resolveReviewHighlighterEnginePreference, type ReviewHighlighterEngine, } from "./reviewHighlighterEngine"; -import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; +import type { ReviewRenderableLineRow } from "./reviewModel"; import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; export type ReviewDiffTheme = "light" | "dark"; @@ -43,17 +43,6 @@ export interface ReviewHighlightedToken { readonly diffHighlight?: boolean; } -export interface ReviewHighlightedFile { - readonly additionLines: ReadonlyArray>; - readonly deletionLines: ReadonlyArray>; -} - -export interface ReviewHighlightFileProgress { - readonly highlightedFile: ReviewHighlightedFile; - readonly complete: boolean; - readonly highlightedLineCount: number; -} - const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", dark: "github-dark-default", @@ -64,16 +53,9 @@ const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, ); -const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( - process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, - false, -); -const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8; const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8; const REVIEW_HIGHLIGHT_CHUNK_SIZE = 200; const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000; -const highlightCache = new Map>(); -const resolvedHighlightCache = new Map(); const REVIEW_INITIAL_LANGUAGE_MODULES = [ bashLanguage, javascriptLanguage, @@ -204,22 +186,6 @@ type LoadedLanguageModule = { default: Parameters[0]; }; -function resolveReviewHighlighterBooleanFlag( - value: string | undefined, - defaultValue: boolean, -): boolean { - switch (value) { - case "1": - case "true": - return true; - case "0": - case "false": - return false; - default: - return defaultValue; - } -} - function isReviewHighlighterDebugLoggingEnabled(): boolean { return typeof __DEV__ !== "undefined" ? __DEV__ : false; } @@ -267,7 +233,6 @@ async function getHighlighter(): Promise { logReviewHighlighterDiagnostic("initializing", { configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, - resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, }); const themes = [githubLightDefault, githubDarkDefault]; @@ -488,13 +453,6 @@ async function resolveLanguageFromPath( return candidate; } -async function resolveLanguage(file: ReviewRenderableFile): Promise { - return ( - resolveLoadedLanguageFromPath(file.path, file.languageHint) ?? - (await resolveLanguageFromPath(file.path, file.languageHint)) - ); -} - function normalizeHighlightedLines( tokenLines: ReadonlyArray>, ): ReadonlyArray> { @@ -507,84 +465,6 @@ function normalizeHighlightedLines( ); } -function makePlainHighlightedLines( - lines: ReadonlyArray, -): ReadonlyArray> { - return lines.map((line) => [ - { - content: stripTrailingNewline(line), - color: null, - fontStyle: null, - }, - ]); -} - -function applyWordAltDiffHighlightsToFile( - file: ReviewRenderableFile, - highlighted: ReviewHighlightedFile, -): ReviewHighlightedFile { - const nextAdditionLines = [...highlighted.additionLines]; - const nextDeletionLines = [...highlighted.deletionLines]; - const processedPairs = new Set(); - let changed = false; - - file.rows.forEach((row) => { - if (row.kind !== "line" || row.change === "context" || !row.comparison) { - return; - } - - const deletionTokenIndex = - row.change === "delete" - ? row.deletionTokenIndex - : row.comparison.change === "delete" - ? row.comparison.tokenIndex - : null; - const additionTokenIndex = - row.change === "add" - ? row.additionTokenIndex - : row.comparison.change === "add" - ? row.comparison.tokenIndex - : null; - - if (deletionTokenIndex === null || additionTokenIndex === null) { - return; - } - - const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`; - if (processedPairs.has(pairKey)) { - return; - } - processedPairs.add(pairKey); - - const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""); - const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""); - const ranges = computeWordAltDiffRanges({ deletionLine, additionLine }); - - if (ranges.deletion.length > 0) { - nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens( - nextDeletionLines[deletionTokenIndex] ?? [], - ranges.deletion, - ); - changed = true; - } - - if (ranges.addition.length > 0) { - nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens( - nextAdditionLines[additionTokenIndex] ?? [], - ranges.addition, - ); - changed = true; - } - }); - - return changed - ? { - additionLines: nextAdditionLines, - deletionLines: nextDeletionLines, - } - : highlighted; -} - function applyWordAltDiffHighlightsToSelectedLines(input: { readonly lines: ReadonlyArray; readonly tokenMap: Record>; @@ -733,292 +613,6 @@ export async function highlightSourceFile(input: { return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); } -async function highlightPatchLinesInChunks(input: { - readonly lines: ReadonlyArray; - readonly language: string; - readonly theme: string; - readonly onChunk: ( - startIndex: number, - tokens: ReadonlyArray>, - ) => void; -}): Promise>> { - if (input.lines.length === 0) { - return []; - } - - const highlighter = await getHighlighter(); - const highlightedLines: Array> = []; - - for ( - let startIndex = 0; - startIndex < input.lines.length; - startIndex += REVIEW_HIGHLIGHT_CHUNK_SIZE - ) { - const lineChunk = input.lines.slice(startIndex, startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE); - const chunkTokens: Array> = []; - const tokenizableLines: string[] = []; - const tokenizableIndexes: number[] = []; - - lineChunk.forEach((line, index) => { - const strippedLine = stripTrailingNewline(line); - if (strippedLine.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { - chunkTokens[index] = [{ content: strippedLine, color: null, fontStyle: null }]; - return; - } - - tokenizableIndexes.push(index); - tokenizableLines.push(strippedLine); - }); - - if (tokenizableLines.length > 0) { - const tokenLines = highlighter.codeToTokensBase(tokenizableLines.join("\n"), { - lang: input.language, - theme: input.theme, - }); - const normalizedTokenLines = normalizeHighlightedLines(tokenLines); - - tokenizableIndexes.forEach((chunkIndex, tokenIndex) => { - chunkTokens[chunkIndex] = normalizedTokenLines[tokenIndex] ?? []; - }); - } - - const completedChunk = lineChunk.map((_, index) => chunkTokens[index] ?? []); - highlightedLines.push(...completedChunk); - input.onChunk(startIndex, completedChunk); - - if (startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE < input.lines.length) { - await waitForNextFrame(); - } - } - - return highlightedLines; -} - -function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { - return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; -} - -function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void { - if (resolvedHighlightCache.has(cacheKey)) { - resolvedHighlightCache.delete(cacheKey); - } - - resolvedHighlightCache.set(cacheKey, highlighted); - - while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) { - const oldestKey = resolvedHighlightCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - resolvedHighlightCache.delete(oldestKey); - } -} - -export async function highlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return resolved; - } - const cached = highlightCache.get(cacheKey); - if (cached) { - logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return cached; - } - } - - const promise = (async () => { - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines = await highlightLines( - joinPatchLines(file.additionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - const deletionLines = await highlightLines( - joinPatchLines(file.deletionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - })(); - - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.set(cacheKey, promise); - } - return promise.finally(() => { - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.delete(cacheKey); - } - }); -} - -export async function streamHighlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, - onProgress: (progress: ReviewHighlightFileProgress) => void, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - onProgress({ - highlightedFile: resolved, - complete: true, - highlightedLineCount: resolved.additionLines.length + resolved.deletionLines.length, - }); - return resolved; - } - } - - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file stream highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines: Array> = []; - const deletionLines: Array> = []; - let highlightedLineCount = 0; - - await highlightPatchLinesInChunks({ - lines: file.additionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - additionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - await waitForNextFrame(); - await highlightPatchLinesInChunks({ - lines: file.deletionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - deletionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount, - durationMs: Date.now() - startedAt, - }); - return highlighted; -} - export async function highlightReviewSelectedLines(input: { readonly filePath: string; readonly lines: ReadonlyArray; diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 5911d2eebc..f32b9b18c5 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -18,7 +18,7 @@ vi.mock("../../state/use-atom-command", () => ({ import type { ComposerCommandMenuProvider } from "./use-composer-command-menu"; -const { buildComposerCommandItems, composerCommandReplacement, composerSelectionAtEnd } = +const { buildComposerCommandItems, composerCommandReplacement } = await import("./use-composer-command-menu"); function skill(overrides: Partial & { name: string }): ServerProviderSkill { @@ -235,9 +235,3 @@ describe("composerCommandReplacement", () => { ).toBe("/model "); }); }); - -describe("composerSelectionAtEnd", () => { - it("resets a changed draft owner to the new draft end", () => { - expect(composerSelectionAtEnd("queued task 🧪")).toEqual({ start: 14, end: 14 }); - }); -}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 8eada265ab..ff1653c14f 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -284,7 +284,7 @@ export function composerCommandReplacement(item: ComposerCommandItem): string | } } -export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { +function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { return { start: draftMessage.length, end: draftMessage.length }; } diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index af09637b4e..417a66138d 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_BASE_FONT_SIZE, - deriveCodeFontSize, - deriveTerminalFontSize, normalizeBaseFontSize, - normalizeCodeFontSize, - normalizeCodeWordBreak, resolveAppearance, resolveAppearancePreferences, resolveMarkdownFontSizes, @@ -48,10 +44,8 @@ describe("appearancePreferences", () => { expect(appearance.isCodeFontSizeCustom).toBe(false); const scaled = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 22 })); - expect(scaled.terminalFontSize).toBe(deriveTerminalFontSize(22)); - expect(scaled.codeFontSize).toBe(deriveCodeFontSize(22)); - expect(scaled.terminalFontSize).toBeGreaterThan(10); - expect(scaled.codeFontSize).toBeGreaterThan(11); + expect(scaled.terminalFontSize).toBe(14); + expect(scaled.codeFontSize).toBe(17); }); it("applies explicit overrides over derived values", () => { @@ -67,8 +61,8 @@ describe("appearancePreferences", () => { it("clamps base and code font sizes", () => { expect(normalizeBaseFontSize(4)).toBe(11); expect(normalizeBaseFontSize(30)).toBe(22); - expect(normalizeCodeFontSize(4)).toBe(8); - expect(normalizeCodeFontSize(30)).toBe(18); + expect(resolveAppearancePreferences({ codeFontSize: 4 }).codeFontSize).toBe(8); + expect(resolveAppearancePreferences({ codeFontSize: 30 }).codeFontSize).toBe(18); }); it("steps terminal font size within bounds", () => { @@ -92,9 +86,8 @@ describe("appearancePreferences", () => { }); }); - it("defaults code word break to false", () => { - expect(normalizeCodeWordBreak(undefined)).toBe(false); - expect(normalizeCodeWordBreak(true)).toBe(true); + it("keeps explicit code word break enabled", () => { + expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); }); it("returns the authored text scale at the 16pt default", () => { diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index b81c500565..2ce6a8b5a3 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -75,7 +75,7 @@ export function normalizeBaseFontSize(value: number | null | undefined): number return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeFontSize(value: number | null | undefined): number { +function normalizeCodeFontSize(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_CODE_FONT_SIZE; } @@ -83,18 +83,18 @@ export function normalizeCodeFontSize(value: number | null | undefined): number return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { +function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { return value === true; } /** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */ -export function deriveTerminalFontSize(baseFontSize: number): number { +function deriveTerminalFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2); } /** Code/diff size derived from base: 12pt at base 16. */ -export function deriveCodeFontSize(baseFontSize: number): number { +function deriveCodeFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale)); } diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index b1722a137c..83411c05fc 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { constrainAuxiliaryPaneWidth, - constrainPrimarySidebarWidth, deriveCenteredContentHorizontalPadding, deriveFileInspectorPaneLayout, deriveLayout, - deriveStableFormSheetDetent, deriveThreadFeedInitialContentInset, deriveWorkspacePaneLayout, SPLIT_LAYOUT_MIN_HEIGHT, @@ -36,12 +34,6 @@ describe("deriveThreadFeedInitialContentInset", () => { }); describe("resizable pane constraints", () => { - it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { - expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); - expect(constrainPrimarySidebarWidth(430, 744)).toBe(384); - expect(constrainPrimarySidebarWidth(100, 1_366)).toBe(280); - }); - it("preserves a useful main pane while constraining a trailing pane", () => { expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 900 })).toBe(340); @@ -353,14 +345,3 @@ describe("deriveWorkspacePaneLayout", () => { }); }); }); - -describe("deriveStableFormSheetDetent", () => { - it.each([ - { height: 1_194, expected: 0.62 }, - { height: 834, expected: 0.863 }, - { height: 600, expected: 0.893 }, - { height: 0, expected: 0.92 }, - ])("derives a stable sheet detent for height $height", ({ height, expected }) => { - expect(deriveStableFormSheetDetent(height)).toBe(expected); - }); -}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 33438a324c..a41a29a2a6 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -13,7 +13,6 @@ export const SPLIT_LAYOUT_MIN_WIDTH = 720; export const SPLIT_LAYOUT_MIN_HEIGHT = 600; export const SPLIT_SIDEBAR_MIN_WIDTH = 280; -export const SPLIT_SIDEBAR_MAX_WIDTH = 460; const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; @@ -24,10 +23,6 @@ export const AUXILIARY_PANE_MAX_WIDTH = 480; const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320; const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; -const STABLE_FORM_SHEET_MAX_HEIGHT = 720; -const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; -const STABLE_FORM_SHEET_MIN_DETENT = 0.62; -const STABLE_FORM_SHEET_MAX_DETENT = 0.92; export type LayoutVariant = "compact" | "split"; @@ -192,22 +187,6 @@ export function deriveFileInspectorPaneLayout(input: { }; } -/** Keep a user-selected sidebar width useful as a window is resized. */ -export function constrainPrimarySidebarWidth( - preferredWidth: number, - viewportWidth = Number.POSITIVE_INFINITY, -): number { - const safeWidth = Number.isFinite(preferredWidth) ? preferredWidth : SPLIT_SIDEBAR_MIN_WIDTH; - const viewportMax = Number.isFinite(viewportWidth) - ? Math.max(SPLIT_SIDEBAR_MIN_WIDTH, viewportWidth - 360) - : SPLIT_SIDEBAR_MAX_WIDTH; - return clamp( - Math.round(safeWidth), - SPLIT_SIDEBAR_MIN_WIDTH, - Math.min(SPLIT_SIDEBAR_MAX_WIDTH, viewportMax), - ); -} - /** * Keep an auxiliary pane within native-feeling bounds without squeezing its * neighboring content below a usable reading/editor width. @@ -249,20 +228,3 @@ export function deriveCenteredContentHorizontalPadding(input: { return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); } - -export function deriveStableFormSheetDetent(containerHeight: number): number { - if (!Number.isFinite(containerHeight) || containerHeight <= 0) { - return STABLE_FORM_SHEET_MAX_DETENT; - } - - const targetHeight = Math.min( - STABLE_FORM_SHEET_MAX_HEIGHT, - Math.max(0, containerHeight - STABLE_FORM_SHEET_VERTICAL_MARGIN), - ); - const detent = clamp( - targetHeight / containerHeight, - STABLE_FORM_SHEET_MIN_DETENT, - STABLE_FORM_SHEET_MAX_DETENT, - ); - return Math.round(detent * 1_000) / 1_000; -} diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 07e77da835..8a402c5ec3 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -136,7 +136,6 @@ import { copyComposerDraftContentIfEmpty, copyComposerDraftContentState, decodePersistedComposerState, - decodePersistedComposerDrafts, ensureComposerDraftsLoaded, type ComposerDraft, flushComposerDrafts, @@ -228,12 +227,12 @@ describe("mobile composer drafts", () => { }; expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }); @@ -906,9 +905,32 @@ describe("mobile composer drafts", () => { ).toEqual(restored); }); + it("rejects persisted images without image bytes or a file URI", () => { + expect(() => + decodePersistedComposerState({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { + text: "Saved image", + attachments: [ + { + id: "image-1", + type: "image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + previewUri: "file:///photo.png", + }, + ], + }, + }, + }), + ).toThrow(); + }); + it("hydrates selector state even when the message content is empty", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "new-task:environment-1:project-1": { @@ -929,7 +951,7 @@ describe("mobile composer drafts", () => { }, }, }, - }), + }).drafts, ).toEqual({ "new-task:environment-1:project-1": { text: "", @@ -953,18 +975,18 @@ describe("mobile composer drafts", () => { it("keeps legacy content-only drafts and rejects invalid selector state", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": DRAFT, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": DRAFT, }); expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 1e932a7994..a04dbb44a6 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -256,10 +256,6 @@ export function decodePersistedComposerState(value: unknown): { }; } -export function decodePersistedComposerDrafts(value: unknown): Record { - return decodePersistedComposerState(value).drafts; -} - async function getComposerDraftsFile() { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 1e0c88c24e..f3cc88d1b5 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -13,31 +13,11 @@ import * as Terminal from "effect/Terminal"; import * as BootService from "../cloud/bootService.ts"; import { acquireRelayClientForLink, - formatHeadlessAuthorizationPrompt, - formatRelayClientReady, headlessSessionConfig, - isPublishAgentActivityEnabledValue, reportCloudDisconnectResults, } from "./connect.ts"; import { recoverServiceOnboardingOffer } from "./service.ts"; -it("explains how to complete headless authorization", () => { - assert.equal( - formatHeadlessAuthorizationPrompt("https://example.test/connect"), - [ - "Headless authorization", - "Open this URL on a device with a browser:", - " https://example.test/connect", - "", - "After signing in, return here and enter the code shown in your browser.", - ].join("\n"), - ); -}); - -it("formats relay readiness without printing its installation path", () => { - assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2"); -}); - const readHeadlessSessionConfig = (env: Record) => headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); @@ -209,10 +189,3 @@ it.effect("keeps disconnect causes in structured logs and out of console warning ), ); }); - -it("treats only the literal 'true' as publish-enabled", () => { - assert.equal(isPublishAgentActivityEnabledValue("true"), true); - assert.equal(isPublishAgentActivityEnabledValue("false"), false); - assert.equal(isPublishAgentActivityEnabledValue(null), false); - assert.equal(isPublishAgentActivityEnabledValue("TRUE"), false); -}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index c11ed09e27..c4aba1e3ca 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -86,7 +86,7 @@ const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_ }, ); -export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { +function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { return [ "Headless authorization", "Open this URL on a device with a browser:", @@ -144,10 +144,6 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -export function isPublishAgentActivityEnabledValue(value: string | null): boolean { - return isAgentActivityPublishingEnabledValue(value); -} - interface CloudCliStatus { readonly desired: boolean; readonly authenticated: boolean; @@ -465,7 +461,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* (identity ? ` as ${identity}` : ""); -export function formatRelayClientReady(version: string): string { +function formatRelayClientReady(version: string): string { return `✓ Relay client ready · cloudflared ${version}`; } @@ -574,7 +570,7 @@ const connectStatusCommand = Command.make("status", { linked: Option.isSome(cloudUserId), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, - publishAgentActivity: isPublishAgentActivityEnabledValue( + publishAgentActivity: isAgentActivityPublishingEnabledValue( Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null, ), relayClient: executable, diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index bc68ef9ad5..33a0f12249 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -93,19 +93,6 @@ class PromptRejectedError extends Schema.TaggedErrorClass() { message: Schema.String }, ) {} -it("formats loopback authorization with a headless-host fallback", () => { - assert.equal( - CliTokenManager.formatLoopbackAuthorizationPrompt("https://clerk.example.test/authorize"), - [ - "Open this URL to authorize Pylon Connect:", - " https://clerk.example.test/authorize", - "", - "Press \u001b[1mEnter\u001b[22m to open it in your browser.", - "No browser on this device? Press \u001b[1mH\u001b[22m to switch to headless mode.", - ].join("\n"), - ); -}); - const makeTestTerminal = (queue: Queue.Queue) => Terminal.make({ columns: Effect.succeed(80), diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 4d7c78469b..772f8f6c7b 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -44,7 +44,7 @@ const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; -export function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { +function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { return [ "Open this URL to authorize Pylon Connect:", ` ${authorizationUrl}`, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 0e80f9d764..1f49fb6898 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -187,32 +187,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("ships configurable thread navigation defaults", () => - Effect.sync(() => { - const defaultsByCommand = new Map( - Keybindings.DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), - ); - - assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); - assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); - assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); - assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); - assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); - assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); - assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); - assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); - assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); - assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); - assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); - assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized")); - assert.equal(defaultsByCommand.get("rightPanel.close"), "mod+w"); - assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); - assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); - }), - ); - it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2887f97695..7073711cad 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -65,7 +65,6 @@ import type { OrchestrationDispatchError } from "../Errors.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { - providerErrorLabel, providerErrorLabelFromInstanceHint, providerFollowUpInputFromMessage, PROVIDER_TURN_ADMISSION_TIMEOUT_MS, @@ -172,10 +171,6 @@ describe("ProviderCommandReactor", () => { }), ).toBe("claude_openrouter"); }); - - it("uses the unknown driver kind when the resolved driver is not registered locally", () => { - expect(providerErrorLabel("third_party_driver")).toBe("third_party_driver"); - }); }); it("omits blank text while preserving image-only follow-up attachments", () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 91155bf4f8..5abee4b6c4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -251,7 +251,7 @@ function formatThreadTitleContext(messages: ReadonlyArray): }; } -export function providerErrorLabel(value: string | undefined): string { +function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : "unknown"; } diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index a6b71be247..2e5a086580 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -21,7 +21,7 @@ Do not switch to global browser skills, Chrome, Node REPL browser automation, st const browserToolInstructions = (browserToolsAvailable: boolean): string => browserToolsAvailable ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""; -export const codexPlanModeDeveloperInstructions = ( +const codexPlanModeDeveloperInstructions = ( browserToolsAvailable: boolean, ): string => `# Plan Mode (Conversational) @@ -154,7 +154,7 @@ If the user stays in Plan mode and asks for revisions after a prior \``; -export const codexDefaultModeDeveloperInstructions = ( +const codexDefaultModeDeveloperInstructions = ( browserToolsAvailable: boolean, ): string => `# Collaboration Mode: Default diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index 13415bc35d..ce8a31b519 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,141 +1,172 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); -describe("parseGrokInspectSkills", () => { - it("maps inspect entries onto provider skills, sorted by name", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { - name: "writing-docs", - description: "Write user docs.", - source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, - userInvocable: true, - }, +const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array) => + ChildProcessSpawner.make((command) => { + spawnCwds?.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + +describe("discoverGrokSkills", () => { + it.effect("maps inspect entries onto provider skills, sorted by name", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + + expect(skills).toEqual([ { name: "deploy", description: "Deploy the app.", - source: { - type: "plugin", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - }, - userInvocable: true, + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + scope: "plugin", + enabled: true, }, - ]), - ); + { + name: "writing-docs", + description: "Write user docs.", + path: "/home/dev/.grok/skills/writing-docs/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "writing-docs", + description: "Write user docs.", + source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, + userInvocable: true, + }, + { + name: "deploy", + description: "Deploy the app.", + source: { + type: "plugin", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + }, + userInvocable: true, + }, + ]), + ), + ), + ), + ); - expect(skills).toEqual([ - { - name: "deploy", - description: "Deploy the app.", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - scope: "plugin", - enabled: true, - }, - { - name: "writing-docs", - description: "Write user docs.", - path: "/home/dev/.grok/skills/writing-docs/SKILL.md", - scope: "user", - enabled: true, - }, - ]); - }); + it.effect("disables skills the CLI marks as not user-invocable", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); - it("disables skills the CLI marks as not user-invocable", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ + expect(skills).toEqual([ { name: "internal-helper", - source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" }, - userInvocable: false, + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + scope: "bundled", + enabled: false, }, - ]), - ); - - expect(skills).toEqual([ - { - name: "internal-helper", - path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", - scope: "bundled", - enabled: false, - }, - ]); - }); - - it("skips entries without a name or a filesystem path", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, - { name: "no-path", source: { type: "user" } }, - { name: "no-source" }, - "not-an-object", - { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, - ]), - ); + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "internal-helper", + source: { + type: "bundled", + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + }, + userInvocable: false, + }, + ]), + ), + ), + ), + ); - expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - }); + it.effect("skips entries without a name or a filesystem path", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, + { name: "no-path", source: { type: "user" } }, + { name: "no-source" }, + "not-an-object", + { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + ]), + ), + ), + ), + ); - it("returns an empty list for malformed or unexpected output", () => { - expect(parseGrokInspectSkills("not json")).toEqual([]); - expect(parseGrokInspectSkills("null")).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]); - }); -}); + it.effect("rejects malformed or unexpected output as a decode failure", () => + Effect.gen(function* () { + for (const stdout of ["not json", "null", '{"skills":"nope"}', "{}"]) { + const error = yield* discoverGrokSkills({ binaryPath: "grok" }, {}).pipe( + Effect.flip, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout), + ), + ); + expect(error).toMatchObject({ _tag: "GrokSkillsProbeError", stage: "decode" }); + } + }), + ); -describe("discoverGrokSkills", () => { it.effect("spawns in the configured cwd and rejects a failed probe", () => { const spawnCwds: Array = []; - let exitCode = 0; - const spawner = ChildProcessSpawner.make((command) => { - spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText( - Stream.make( - inspectPayload([ - { - name: "kept", - source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, - }, - ]), - ), - ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ); - }); + const stdout = inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]); return Effect.gen(function* () { const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "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/workspaces/demo").pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 0, spawnCwds), + ), ); expect(spawnCwds).toEqual(["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/workspaces/demo"]); expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - exitCode = 1; const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe( Effect.result, - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 1), + ), ); expect(failed._tag).toBe("Failure"); }); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index b7962205d3..53a20049ad 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -91,10 +91,6 @@ function decodeGrokInspectSkills(stdout: string): ReadonlyArray left.name.localeCompare(right.name)); } -export function parseGrokInspectSkills(stdout: string): ReadonlyArray { - return decodeGrokInspectSkills(stdout) ?? []; -} - /** * Run `grok inspect --json` and map the reported catalog onto provider * skills. Callers that need best-effort discovery can recover this effect to diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index c3a17557c5..9a6aa2e7a7 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -9,11 +9,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { - buildCodexDeveloperInstructions, - codexDefaultModeDeveloperInstructions, - codexPlanModeDeveloperInstructions, -} from "../CodexDeveloperInstructions.ts"; +import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, @@ -459,7 +455,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "high", }); - NodeAssert.ok(instructions.startsWith(codexDefaultModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Collaboration Mode: Default/); NodeAssert.match(instructions, /Pylon/); NodeAssert.match(instructions, /Codex harness/); NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); @@ -471,7 +467,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "medium", }); - NodeAssert.ok(instructions.startsWith(codexPlanModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Plan Mode/); NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); }); @@ -500,11 +496,11 @@ describe("buildCodexDeveloperInstructions", () => { }); describe("T3 browser developer instructions", () => { + const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; + it("prefers the product-native preview tools in both collaboration modes", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(true), - codexPlanModeDeveloperInstructions(true), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, true); NodeAssert.match(instructions, /t3-code/); NodeAssert.match(instructions, /preview_status/); NodeAssert.match(instructions, /preview_open/); @@ -513,10 +509,8 @@ describe("T3 browser developer instructions", () => { }); it("omits the browser block entirely when the preview tools are not attached", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(false), - codexPlanModeDeveloperInstructions(false), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, false); NodeAssert.doesNotMatch(instructions, /preview_status/); NodeAssert.doesNotMatch(instructions, /preview_open/); NodeAssert.doesNotMatch(instructions, /Pylon collaborative browser/); @@ -530,7 +524,6 @@ describe("T3 browser developer instructions", () => { }); it("tracks the turn's MCP configuration rather than defaulting to on", () => { - const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); NodeAssert.doesNotMatch( buildCodexDeveloperInstructions("default", runtime, false), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 87e5648f44..8fed6bf28c 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -16,7 +16,6 @@ import { buildCursorCapabilitiesFromConfigOptions, checkCursorProviderStatus, discoverCursorModelsViaAcp, - getCursorFallbackModels, makeCursorModelDiscovery, getCursorParameterizedModelPickerUnsupportedMessage, parseCursorAboutOutput, @@ -465,16 +464,6 @@ describe("Cursor skills", () => { }); }); -describe("getCursorFallbackModels", () => { - it("does not publish any built-in cursor models before ACP discovery", () => { - expect( - getCursorFallbackModels({ - customModels: ["internal/cursor-model"], - }).map((model) => model.slug), - ).toEqual(["internal/cursor-model"]); - }); -}); - describe("buildCursorProviderSnapshot", () => { it("downgrades ready status to warning when ACP model discovery times out", () => { expect( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 44f50ba21e..ca23c01b4f 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -591,7 +591,7 @@ export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(fu Cache.get(cache, JSON.stringify([about.version, about.auth])); }); -export function getCursorFallbackModels( +function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index aff32f676f..d55ab9aa6a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -42,7 +42,6 @@ import { type OpenCodeRuntimeShape, } from "../opencodeRuntime.ts"; import { - appendOpenCodeAssistantTextDelta, isOpenCodeNotFound, isSameOpenCodeDirectory, makeOpenCodeAdapter, @@ -5702,20 +5701,17 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.scoped), ); - it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => + it.effect("reconciles assistant text snapshots", () => Effect.sync(() => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); - const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); - const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); const appendedUpdate = mergeOpenCodeAssistantText("Hello", "Hello world"); const changedUpdate = mergeOpenCodeAssistantText("Hello world", "Hello there"); const staleUpdate = mergeOpenCodeAssistantText("Hello world", "Hello"); - NodeAssert.deepEqual( - [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], - ["Hello", "lo world", ""], - ); - NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); + NodeAssert.deepEqual(firstUpdate, { + latestText: "Hello", + deltaToEmit: "Hello", + }); NodeAssert.deepEqual(appendedUpdate, { latestText: "Hello world", deltaToEmit: " world", diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 7dd3a667a4..1250554d7a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -578,7 +578,7 @@ export function mergeOpenCodeAssistantText( }; } -export function appendOpenCodeAssistantTextDelta( +function appendOpenCodeAssistantTextDelta( previousText: string, delta: string, ): { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 0d89d3b12c..77c72dda71 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -41,7 +41,6 @@ import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { - haveProvidersChanged, mergeProviderCapacityRefresh, mergeProviderSnapshot, upsertProviderWorkspaceSnapshot, @@ -720,39 +719,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); describe("ProviderRegistryLive", () => { - it("treats equal provider snapshots as unchanged", () => { - const providers = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "warning", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - - assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); - }); - it("stores workspace skills and commands without changing machine metadata", () => { const provider = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 4fc3dd1178..0bbd7e905d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -246,7 +246,7 @@ export function mergeProviderCapacityRefresh(input: { }); } -export const haveProvidersChanged = ( +const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 36ef218569..cf369ed0d7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -39,6 +39,7 @@ import { type ProjectionSnapshotQueryShape, } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { + isAgentActivityPublishingEnabledValue, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, RELAY_URL_SECRET, @@ -220,9 +221,10 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }); it("requires an explicit opt-in before publishing agent activity", () => { - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled(null)).toBe(false); - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("false")).toBe(false); - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("true")).toBe(true); + expect(isAgentActivityPublishingEnabledValue(null)).toBe(false); + expect(isAgentActivityPublishingEnabledValue("false")).toBe(false); + expect(isAgentActivityPublishingEnabledValue("TRUE")).toBe(false); + expect(isAgentActivityPublishingEnabledValue("true")).toBe(true); }); it("redacts failed activity details and caps other relay detail", () => { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index a1b4c9e062..0ce64c6f2d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -102,10 +102,6 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n return JSON.stringify(meaningfulState); } -export function isAgentActivityPublishingEnabled(value: string | null): boolean { - return isAgentActivityPublishingEnabledValue(value); -} - export function resolveAgentActivityPublishingStartupState(input: { readonly relayConfigured: boolean; readonly publishEnabled: boolean; @@ -322,7 +318,7 @@ export const make = Effect.gen(function* () { }); const readPublishAgentActivityEnabled = readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET).pipe( - Effect.map(isAgentActivityPublishingEnabled), + Effect.map(isAgentActivityPublishingEnabledValue), ); const makeRelayClient = (relayConfig: { diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 4445b8da9b..8386ae2711 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -13,16 +13,8 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; -it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => { - assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); -}); - it.effect("enqueueCommand waits for readiness and then drains queued work", () => Effect.scoped( Effect.gen(function* () { @@ -70,56 +62,6 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); -it.effect("launchStartupHeartbeat does not block the caller while counts are loading", () => - Effect.scoped( - Effect.gen(function* () { - const releaseCounts = yield* Deferred.make(); - const countsStarted = yield* Deferred.make(); - - yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( - Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { - getCommandReadModel: () => Effect.die("unused"), - getSnapshot: () => Effect.die("unused"), - getShellSnapshot: () => Effect.die("unused"), - getArchivedShellSnapshot: () => Effect.die("unused"), - getSnapshotSequence: () => Effect.die("unused"), - getEventReplayStats: () => Effect.die("unused"), - getCounts: () => - Deferred.succeed(countsStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCounts)), - Effect.as({ - projectCount: 2, - threadCount: 3, - }), - ), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getThreadRuntimeContext: () => Effect.die("unused"), - getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), - searchThreads: () => Effect.succeed({ matches: [] }), - }), - Effect.provideService(AnalyticsService.AnalyticsService, { - record: () => Effect.void, - flush: Effect.void, - }), - ); - - // The heartbeat is forked, so the caller is already back here while - // getCounts is still parked. Awaiting countsStarted proves the forked - // work really ran; releaseCounts staying incomplete proves the caller - // never waited for it. - yield* Deferred.await(countsStarted); - assert.equal(yield* Deferred.isDone(releaseCounts), false); - }), - ), -); - it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( @@ -160,7 +102,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa id: bootstrapProjectId, title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -258,10 +203,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when ["project.create", "thread.create"], ); assert.equal("defaultModelSelection" in commands[0]!, false); - assert.deepStrictEqual( - commands[1]?.modelSelection, - ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), - ); + assert.deepStrictEqual(commands[1]?.modelSelection, { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }); }), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 09aac1b4c1..0e6090e60e 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -160,15 +160,7 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( - Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), - Effect.withSpan("server.startup.heartbeat.record"), - Effect.ignoreCause({ log: true }), - Effect.forkScoped, - Effect.asVoid, -); - -export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ +const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index f1f39122d2..fefcff7c29 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -146,7 +146,7 @@ describe("buildBranchNamePrompt", () => { }); describe("buildThreadTitlePrompt", () => { - it("includes the user message and the title guidance rules", () => { + it("includes the user message without absent attachment metadata", () => { const result = buildThreadTitlePrompt({ message: "Investigate reconnect regressions after session restore", }); @@ -154,18 +154,7 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("User message:"); expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); expect(result.prompt).not.toContain("Attachment metadata:"); - expect(result.prompt).toContain( - "Generate a title that will help the user recognize this Pylon thread weeks later.", - ); - expect(result.prompt).toContain( - "Title the subject and outcome. Discard incidental instructions.", - ); - expect(result.prompt).toContain( - "Name the product change, not the mock, plan, report, branch, or PR used to produce it.", - ); - expect(result.prompt).not.toContain( - "Title should summarize the user's request, not restate it verbatim.", - ); + expect(result.prompt).toContain("this Pylon thread"); }); it("includes attachment metadata when attachments are provided", () => { @@ -188,24 +177,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("67890 bytes"); }); - it.each([ - { mode: "initial", previousTitle: undefined }, - { mode: "regeneration", previousTitle: "Open Projects in Desktop App" }, - ])( - "tells the $mode prompt not to title linked PRs from local git history", - ({ previousTitle }) => { - const result = buildThreadTitlePrompt({ - message: "$takeover https://github.com/pingdotgg/t3code/pull/8588", - ...(previousTitle === undefined ? {} : { previousTitle }), - }); - - expect(result.prompt).toContain( - "Local git history is not evidence of what a linked PR or issue is about.", - ); - expect(result.prompt).toContain('such as "Take Over PR 8588"'); - }, - ); - it("regenerates from recent thread contents and identifies the previous title", () => { const result = buildThreadTitlePrompt({ message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`, @@ -216,15 +187,6 @@ describe("buildThreadTitlePrompt", () => { "Regenerate the title for an existing Pylon thread so the user can recognize it weeks later.", ); expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); - expect(result.prompt).toContain( - "Read the USER messages first. Identify the latest explicit durable goal.", - ); - expect(result.prompt).toContain( - "Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.", - ); - expect(result.prompt).toContain( - 'A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks,"', - ); expect(result.prompt).toContain("Thread contents:"); expect(result.prompt).toContain("The remaining issue is stale session state"); }); diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 64d60cacec..15a885b4fa 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { lookupRate, normalizeModelName, parseRateTable } from "./usagePricing.ts"; +import { lookupRate, parseRateTable } from "./usagePricing.ts"; const rate = (input: number, cacheRead?: number) => ({ input_cost_per_token: input, @@ -9,10 +9,6 @@ const rate = (input: number, cacheRead?: number) => ({ }); describe("usage pricing", () => { - it("keeps the existing model-name normalization contract", () => { - expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5"); - }); - it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index b397577bdc..7cc5dcc3dc 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -103,14 +103,6 @@ function normalizeRateKey(model: string): string { return model.trim().toLowerCase(); } -/** - * The bare model name a lookup falls back to: a `provider/` prefix stripped and - * lowercased, since transcripts are inconsistent about both. - */ -export function normalizeModelName(model: string): string { - return bareModelName(normalizeRateKey(model)); -} - function bareModelName(key: string): string { const slash = key.lastIndexOf("/"); return slash === -1 ? key : key.slice(slash + 1); @@ -147,7 +139,7 @@ export function countKnownModels(table: RateTable): number { export function lookupRate(table: RateTable, model: string): ModelRate | null { const key = normalizeRateKey(model); - const bareName = normalizeModelName(model); + const bareName = bareModelName(key); if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null; // Exact first, so a reseller's own key keeps its own rate. Then the bare name, // because transcripts record gateway-proxied ids LiteLLM has no key for — diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 38e205beab..1730030d69 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -26,10 +26,7 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; import { - collectCloudLinkTargets, linkPrimaryEnvironmentToCloud, - listManagedCloudEnvironments, - normalizeRelayBaseUrl, readPrimaryCloudLinkState, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, @@ -154,48 +151,6 @@ afterEach(() => { }); describe("web cloud link environment client", () => { - it("normalizes relay URLs and de-duplicates cloud link targets", () => { - expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); - expect( - collectCloudLinkTargets({ - primary: TARGET, - saved: [TARGET, { ...TARGET, environmentId: "environment-2" }], - }).map((target) => target.environmentId), - ).toEqual(["environment-1", "environment-2"]); - }); - - it.effect("lists relay-managed environments through the typed relay client", () => - Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - environments: [ - { - environmentId: "environment-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-06-06T00:00:00.000Z", - }, - ], - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const environments = yield* withServices( - listManagedCloudEnvironments({ clerkToken: "clerk-token" }), - ); - - expect(environments).toHaveLength(1); - expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - }), - ); - it.effect("reads primary cloud link state from the explicit target", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValue( diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 144b5ab0b9..b96f4b97d5 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -17,7 +17,6 @@ import { } from "@t3tools/contracts"; import { type RelayClientDeviceRecord, - type RelayClientEnvironmentRecord, type RelayEnvironmentLinkResponse, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; @@ -38,14 +37,6 @@ import { requestRelayClientInstallConfirmation, } from "./relayClientInstallDialog"; -export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function relayUrl(): string | null { return resolveCloudPublicConfig().relayUrl; } @@ -199,22 +190,6 @@ export interface CloudLinkTarget { export type CloudLinkState = EnvironmentCloudLinkStateResult; -export function collectCloudLinkTargets(input: { - readonly primary: CloudLinkTarget | null; - readonly saved: ReadonlyArray; -}): ReadonlyArray { - const byId = new Map(); - if (input.primary) { - byId.set(input.primary.environmentId, input.primary); - } - for (const environment of input.saved) { - if (!byId.has(environment.environmentId)) { - byId.set(environment.environmentId, environment); - } - } - return [...byId.values()]; -} - export function readPrimaryCloudLinkTarget(): CloudLinkTarget | null { const descriptor = readPrimaryEnvironmentDescriptor(); const target = readPrimaryEnvironmentTarget(); @@ -229,37 +204,6 @@ export function readPrimaryCloudLinkTarget(): CloudLinkTarget | null { }; } -export function listManagedCloudEnvironments(input: { - readonly clerkToken: string; -}): Effect.Effect< - ReadonlyArray, - CloudEnvironmentLinkError, - ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const relayClient = yield* ManagedRelay.ManagedRelayClient; - return yield* relayClient - .listEnvironments({ - clerkToken: input.clerkToken, - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not list relay-managed environments.", - cause, - }), - ), - ); - }); -} - export function listCloudDevices(input: { readonly clerkToken: string; }): Effect.Effect< diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 8bdc6805d5..c69208b0e3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -8,11 +8,9 @@ import { createThreadJumpHintVisibilityController, filterSidebarProjectScopeItems, getSidebarThreadIdsToPrewarm, - getVisibleSidebarThreadIds, resolveAdjacentThreadId, reduceSidebarProjectScopeMenuState, getFallbackThreadIdAfterDelete, - getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, @@ -27,7 +25,6 @@ import { resolveWorkingStartedAt, searchSidebarThreadsByTitle, formatWorkingDurationLabel, - shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebar, @@ -83,56 +80,6 @@ describe("animatePinnedLayoutChanges", () => { }); }); -describe("shouldNavigateAfterProjectRemoval", () => { - const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; - - it("navigates away from a draft route owned by the removed project", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { kind: "draft", draftId: "draft-1" as never }, - projectThreads, - projectDraftId: "draft-1", - }), - ).toBe(true); - }); - - it("does not navigate away from a different draft route", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { kind: "draft", draftId: "draft-2" as never }, - projectThreads, - projectDraftId: "draft-1", - }), - ).toBe(false); - }); - - it("navigates away from a server thread owned by the removed project", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { - kind: "server", - threadRef: { - environmentId: EnvironmentId.make("environment-local"), - threadId: ThreadId.make("thread-1"), - }, - }, - projectThreads, - projectDraftId: null, - }), - ).toBe(true); - }); - - it("does not navigate from an unrelated route", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: null, - projectThreads, - projectDraftId: null, - }), - ).toBe(false); - }); -}); - describe("archiveSelectedThreadEntries", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; const success = { _tag: "Success" } as const; @@ -585,46 +532,6 @@ describe("resolveAdjacentThreadId", () => { }); }); -describe("getVisibleSidebarThreadIds", () => { - it("returns only the rendered visible thread order across projects", () => { - expect( - getVisibleSidebarThreadIds([ - { - renderedThreadIds: [ - ThreadId.make("thread-12"), - ThreadId.make("thread-11"), - ThreadId.make("thread-10"), - ], - }, - { - renderedThreadIds: [ThreadId.make("thread-8"), ThreadId.make("thread-6")], - }, - ]), - ).toEqual([ - ThreadId.make("thread-12"), - ThreadId.make("thread-11"), - ThreadId.make("thread-10"), - ThreadId.make("thread-8"), - ThreadId.make("thread-6"), - ]); - }); - - it("skips threads from collapsed projects whose thread panels are not shown", () => { - expect( - getVisibleSidebarThreadIds([ - { - shouldShowThreadPanel: false, - renderedThreadIds: [ThreadId.make("thread-hidden-2"), ThreadId.make("thread-hidden-1")], - }, - { - shouldShowThreadPanel: true, - renderedThreadIds: [ThreadId.make("thread-12"), ThreadId.make("thread-11")], - }, - ]), - ).toEqual([ThreadId.make("thread-12"), ThreadId.make("thread-11")]); - }); -}); - describe("isContextMenuPointerDown", () => { it("treats secondary-button presses as context menu gestures on all platforms", () => { expect( @@ -1402,57 +1309,6 @@ describe("resolveProjectStatusIndicator", () => { }); }); -describe("getVisibleThreadsForProject", () => { - it("includes the active thread even when it falls below the folded preview", () => { - const threads = Array.from({ length: 8 }, (_, index) => - makeThread({ - id: ThreadId.make(`thread-${index + 1}`), - title: `Thread ${index + 1}`, - }), - ); - - const result = getVisibleThreadsForProject({ - threads, - activeThreadId: ThreadId.make("thread-8"), - isThreadListExpanded: false, - previewLimit: 6, - }); - - expect(result.hasHiddenThreads).toBe(true); - expect(result.visibleThreads.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ThreadId.make("thread-4"), - ThreadId.make("thread-5"), - ThreadId.make("thread-6"), - ThreadId.make("thread-8"), - ]); - expect(result.hiddenThreads.map((thread) => thread.id)).toEqual([ThreadId.make("thread-7")]); - }); - - it("returns all threads when the list is expanded", () => { - const threads = Array.from({ length: 8 }, (_, index) => - makeThread({ - id: ThreadId.make(`thread-${index + 1}`), - }), - ); - - const result = getVisibleThreadsForProject({ - threads, - activeThreadId: ThreadId.make("thread-8"), - isThreadListExpanded: true, - previewLimit: 6, - }); - - expect(result.hasHiddenThreads).toBe(true); - expect(result.visibleThreads.map((thread) => thread.id)).toEqual( - threads.map((thread) => thread.id), - ); - expect(result.hiddenThreads).toEqual([]); - }); -}); - function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1096779c9b..9196cc5bfd 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -10,7 +10,6 @@ import { type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; -import type { ThreadRouteTarget } from "../threadRoutes"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; @@ -393,17 +392,6 @@ export function orderItemsByPreferredIds(input: { return [...ordered, ...remaining]; } -export function getVisibleSidebarThreadIds( - renderedProjects: readonly { - shouldShowThreadPanel?: boolean; - renderedThreadIds: readonly TThreadId[]; - }[], -): TThreadId[] { - return renderedProjects.flatMap((renderedProject) => - renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, - ); -} - export function getSidebarThreadIdsToPrewarm( visibleThreadIds: readonly TThreadId[], limit = SIDEBAR_THREAD_PREWARM_LIMIT, @@ -438,28 +426,6 @@ export function resolveAdjacentThreadId(input: { return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; } -export function shouldNavigateAfterProjectRemoval(input: { - routeTarget: ThreadRouteTarget | null; - projectThreads: readonly { - environmentId: string; - id: string; - }[]; - projectDraftId: string | null; -}): boolean { - const { projectDraftId, projectThreads, routeTarget } = input; - if (routeTarget?.kind === "draft") { - return projectDraftId === routeTarget.draftId; - } - if (routeTarget?.kind !== "server") { - return false; - } - return projectThreads.some( - (thread) => - thread.environmentId === routeTarget.threadRef.environmentId && - thread.id === routeTarget.threadRef.threadId, - ); -} - export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; @@ -892,54 +858,6 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } -export function getVisibleThreadsForProject>(input: { - threads: readonly T[]; - activeThreadId: T["id"] | undefined; - isThreadListExpanded: boolean; - previewLimit: number; -}): { - hasHiddenThreads: boolean; - visibleThreads: T[]; - hiddenThreads: T[]; -} { - const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; - const hasHiddenThreads = threads.length > previewLimit; - - if (!hasHiddenThreads || isThreadListExpanded) { - return { - hasHiddenThreads, - hiddenThreads: [], - visibleThreads: [...threads], - }; - } - - const previewThreads = threads.slice(0, previewLimit); - if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) { - return { - hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), - visibleThreads: previewThreads, - }; - } - - const activeThread = threads.find((thread) => thread.id === activeThreadId); - if (!activeThread) { - return { - hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), - visibleThreads: previewThreads, - }; - } - - const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id)); - - return { - hasHiddenThreads: true, - hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(thread.id)), - visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)), - }; -} - export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx index 246a6efb8d..00a8e46902 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx @@ -41,20 +41,10 @@ describe("ProviderStatusBanner", () => { expect(markup).toContain('role="alert"'); expect(markup).toContain('aria-label="Dismiss Codex provider warning"'); - expect(markup).toContain("absolute top-2 right-2"); expect(markup).toContain("line-clamp-3"); expect(markup).toContain('data-slot="tooltip-trigger"'); }); - it("renders on a glass surface so the timeline never reads through the banner", () => { - const markup = renderToStaticMarkup( - {}} />, - ); - - expect(markup).toContain("alert-glass"); - expect(markup).toContain('data-variant="warning"'); - }); - it("labels error dismiss controls with the correct severity", () => { const markup = renderToStaticMarkup( ({ - codeViewOptions: null as Record | null, -})); - -vi.mock("@pierre/diffs/react", () => ({ - CodeView: (props: { options: Record }) => { - testState.codeViewOptions = props.options; - return null; - }, -})); - -vi.mock("~/composerDraftStore", () => ({ - useComposerDraftStore: (selector: (store: Record) => unknown) => - selector({ - addReviewComment: vi.fn(), - removeReviewComment: vi.fn(), - getComposerDraft: () => undefined, - }), -})); - -vi.mock("./DiffCommentAnnotation", () => ({ - DiffCommentAnnotation: () => null, -})); - -vi.mock("../files/fileCommentAnnotations", () => ({ - nextFileCommentId: () => "comment-test", -})); - -import { AnnotatableCodeView } from "./AnnotatableCodeView"; - -describe("AnnotatableCodeView", () => { - beforeEach(() => { - testState.codeViewOptions = null; - }); - - it("opens comments from Pierre's gutter action without ending line selection", () => { - renderToStaticMarkup( - null} - renderHeaderFilenameSuffix={() => null} - />, - ); - - expect(testState.codeViewOptions).toMatchObject({ - enableGutterUtility: true, - enableLineSelection: true, - onGutterUtilityClick: expect.any(Function), - }); - expect(testState.codeViewOptions).not.toHaveProperty("onLineSelectionEnd"); - }); -}); diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx deleted file mode 100644 index f0cd49abc4..0000000000 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const testState = vi.hoisted(() => ({ - codeViewClassName: null as string | null, - codeViewOptions: null as Record | null, -})); - -vi.mock("@pierre/diffs/react", () => ({ - CodeView: (props: { className: string; options: Record }) => { - testState.codeViewClassName = props.className; - testState.codeViewOptions = props.options; - return null; - }, -})); - -import { StyledDiffCodeView } from "./StyledDiffCodeView"; - -describe("StyledDiffCodeView", () => { - beforeEach(() => { - testState.codeViewClassName = null; - testState.codeViewOptions = null; - }); - - it("always pairs the shared diff styling with its virtualized geometry", () => { - const loadDiffFiles = vi.fn(async () => ({ - oldFile: { name: "before.ts", contents: "before\n" }, - newFile: { name: "after.ts", contents: "after\n" }, - })); - renderToStaticMarkup( - , - ); - - expect(testState.codeViewClassName).toBe( - "diff-render-surface [--code-background:var(--background)] outline-none min-h-0", - ); - expect(testState.codeViewOptions).toMatchObject({ - theme: "pierre-dark", - stickyHeaders: true, - loadDiffFiles, - itemMetrics: { - diffHeaderHeight: 32, - hunkSeparatorHeight: 24, - paddingTop: 0, - paddingBottom: 8, - }, - layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, - }); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining("[data-unmodified-lines]::before"), - ); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining(")[data-expand-index]\n [data-unmodified-lines]"), - ); - }); -}); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 72e2d4ffac..006f08b8b0 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -67,10 +67,7 @@ import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, finalizePromotedDraftThreadByRef, - markPromotedDraftThread, markPromotedDraftThreadByRef, - markPromotedDraftThreads, - markPromotedDraftThreadsByRef, type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, @@ -1175,7 +1172,7 @@ describe("composerDraftStore project draft thread mapping", () => { interactionMode: "plan", }); store.setPrompt(draftId, "keep this prompt"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, { threadId: retryThreadId, @@ -1338,12 +1335,12 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)).toBeUndefined(); }); - it("marks a promoted draft by thread id without deleting composer state", () => { + it("marks a promoted draft by scoped ref without deleting composer state", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); store.setPrompt(draftId, "promote me"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( @@ -1368,20 +1365,20 @@ describe("composerDraftStore project draft thread mapping", () => { const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); store.setPrompt(threadRef, "keep me"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); }); - it("marks promoted drafts from an iterable of server thread ids", () => { + it("promotes a draft without changing another thread's draft", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); store.setPrompt(draftId, "promote me"); store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId }); store.setPrompt(otherDraftId, "keep me"); - markPromotedDraftThreads([threadId]); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), @@ -1393,7 +1390,7 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(otherDraftId)?.prompt).toBe("keep me"); }); - it("marks every matching scoped draft when multiple environments share a thread id", () => { + it("promotes matching thread ids separately for each environment", () => { const store = useComposerDraftStore.getState(); const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId); @@ -1403,7 +1400,16 @@ describe("composerDraftStore project draft thread mapping", () => { store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId }); store.setPrompt(remoteDraftId, "remote draft"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(localThreadRef); + + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(store.getDraftThreadByProjectRef(remoteProjectRef)?.threadId).toBe(threadId); + expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef); + expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toBeNull(); + expect(draftByKey(localDraftId)?.prompt).toBe("local draft"); + expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft"); + + markPromotedDraftThreadByRef(remoteThreadRef); expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull(); @@ -1426,34 +1432,10 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)?.prompt).toBe("promote me"); }); - it("only marks iterable promotion cleanup entries for the matching environment refs", () => { - const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectRef, draftId, { threadId }); - store.setPrompt(draftId, "promote me"); - - markPromotedDraftThreadsByRef([scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)]); - - expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( - threadId, - ); - expect(draftByKey(draftId)?.prompt).toBe("promote me"); - }); - - it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => { - const store = useComposerDraftStore.getState(); - const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); - store.setPrompt(threadRef, "keep me"); - - markPromotedDraftThreads([threadId]); - - expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); - expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); - }); - it("moves composer edits made during promotion to the canonical thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); store.setPrompt(draftId, "typed during setup"); finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fbff300a72..8a41f40eb4 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4374,29 +4374,6 @@ export function useEffectiveComposerModelState(input: { ); } -/** - * Mark a draft thread as promoting once the server has materialized the same thread id. - * - * Use the single-thread helper for live `thread.created` events and the - * iterable helper for bootstrap/recovery paths that discover multiple server - * threads at once. - */ -export function markPromotedDraftThread(threadId: ThreadId): void { - const store = useComposerDraftStore.getState(); - const draftThreadTargets: ComposerThreadTarget[] = []; - for (const [draftId, draftThread] of Object.entries(store.draftThreadsByThreadKey)) { - if (draftThread.threadId === threadId) { - draftThreadTargets.push(DraftId.make(draftId)); - } - } - if (draftThreadTargets.length === 0) { - return; - } - for (const draftThreadTarget of draftThreadTargets) { - store.markDraftThreadPromoting(draftThreadTarget); - } -} - export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { const draftStore = useComposerDraftStore.getState(); for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { @@ -4409,18 +4386,6 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { } } -export function markPromotedDraftThreads(serverThreadIds: Iterable): void { - for (const threadId of serverThreadIds) { - markPromotedDraftThread(threadId); - } -} - -export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable): void { - for (const threadRef of serverThreadRefs) { - markPromotedDraftThreadByRef(threadRef); - } -} - export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { const draftStore = useComposerDraftStore.getState(); for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 02aa77d689..505f82c041 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -8,8 +8,6 @@ import { } from "@t3tools/contracts"; import { formatShortcutLabel, - isChatNewShortcut, - isChatNewLocalShortcut, isDiffToggleShortcut, modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, @@ -21,8 +19,7 @@ import { isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, resolveShortcutCommand, - shouldShowModelPickerJumpHints, - shouldShowThreadJumpHints, + shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, terminalDeleteShortcutData, terminalNavigationShortcutData, @@ -467,17 +464,21 @@ describe("thread navigation helpers", () => { it("shows jump hints only when configured modifiers match", () => { assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), ); assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - }), + shouldShowThreadJumpHintsForModifiers( + event({ metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), ); assert.isTrue( - shouldShowThreadJumpHints(event({ ctrlKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), ); @@ -485,13 +486,13 @@ describe("thread navigation helpers", () => { it("never shows jump hints while the terminal is focused, even with an unrestricted binding", () => { assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: true }, }), ); assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: false }, }), @@ -508,47 +509,36 @@ describe("model picker navigation helpers", () => { assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.3"), 2); assert.isNull(modelPickerJumpIndexFromCommand("thread.jump.1")); }); - - it("shows jump hints only while the model picker context is active", () => { - assert.isFalse( - shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { modelPickerOpen: false }, - }), - ); - assert.isTrue( - shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { modelPickerOpen: true }, - }), - ); - }); }); describe("chat/editor shortcuts", () => { it("matches chat.new shortcut", () => { - assert.isTrue( - isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), + "chat.new", ); - assert.isTrue( - isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.new", ); }); it("matches chat.newLocal shortcut", () => { - assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), + "chat.newLocal", ); - assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.newLocal", ); }); @@ -668,11 +658,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: true }, }), ); - assert.isFalse( - isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true }), keybindings, { platform: "MacIntel", context: { terminalFocus: true }, }), + "terminal.new", ); assert.isFalse( isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { @@ -680,11 +671,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: false }, }), ); - assert.isTrue( - isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true }), keybindings, { platform: "MacIntel", context: { terminalFocus: false }, }), + "chat.new", ); }); @@ -704,11 +696,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: true }, }), ); - assert.isTrue( - isChatNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true }), keybindings, { platform: "Linux", context: { terminalFocus: true }, }), + "chat.new", ); }); }); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index eb4637df21..422eb007f7 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -288,14 +288,6 @@ export function threadTraversalDirectionFromCommand( return null; } -export function shouldShowThreadJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowThreadJumpHintsForModifiers(event, keybindings, options); -} - export function shouldShowThreadJumpHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, @@ -337,32 +329,6 @@ export function modelPickerJumpIndexFromCommand(command: string): number | null return index === -1 ? null : index; } -export function shouldShowModelPickerJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowModelPickerJumpHintsForModifiers(event, keybindings, options); -} - -export function shouldShowModelPickerJumpHintsForModifiers( - modifiers: ShortcutModifierStateLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - const platform = resolvePlatform(options); - - for (const command of MODEL_PICKER_JUMP_KEYBINDING_COMMANDS) { - const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); - if (!shortcut) continue; - if (matchesShortcutModifiers(modifiers, shortcut, platform)) { - return true; - } - } - - return false; -} - export function isTerminalToggleShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, @@ -435,22 +401,6 @@ export function isPreviewFocusUrlShortcut( return matchesCommandShortcut(event, keybindings, "preview.focusUrl", options); } -export function isChatNewShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "chat.new", options); -} - -export function isChatNewLocalShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); -} - export function isOpenFavoriteEditorShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 862fe46081..f9956f9c02 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -19,10 +19,10 @@ import { reconcilePreviewServerSessions, rememberPreviewUrl, resetPreviewStateForTests, - subscribeThreadPreviewState, setActivePreviewTab, updatePreviewServerSnapshot, } from "./previewStateStore"; +import { appAtomRegistry } from "./rpc/atomRegistry"; const environmentId = "env-1" as EnvironmentId; const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); @@ -352,7 +352,7 @@ describe("previewStateStore (single-tab)", () => { }, }; let updateCount = 0; - const unsubscribe = subscribeThreadPreviewState(ref, () => { + const unsubscribe = appAtomRegistry.subscribe(previewStateAtom(scopedThreadKey(ref)), () => { updateCount += 1; }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 90f8e27c35..1e7ec37066 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -173,19 +173,6 @@ export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref))); } -export function subscribeThreadPreviewState( - ref: ScopedThreadRef, - listener: (state: ThreadPreviewState, previous: ThreadPreviewState) => void, -): () => void { - const atom = previewStateAtom(scopedThreadKey(ref)); - let previous = appAtomRegistry.get(atom); - return appAtomRegistry.subscribe(atom, (state) => { - const prior = previous; - previous = state; - listener(state, prior); - }); -} - export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { updateThreadPreviewState(ref, (current) => { if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 033de73a1c..69d73b05ab 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -378,16 +378,6 @@ export function formatDuration(durationMs: number): string { return `${minutes}m ${seconds}s`; } -export function formatElapsed(startIso: string, endIso: string | undefined): string | null { - if (!endIso) return null; - const startedAt = Date.parse(startIso); - const endedAt = Date.parse(endIso); - if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { - return null; - } - return formatDuration(endedAt - startedAt); -} - type LatestTurnTiming = Pick; type SessionActivityState = Pick, "status" | "activeTurnId">; diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 16a145000f..aa29086608 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -34,7 +34,6 @@ import { OCEAN_THEME, updateCustomTheme, CUSTOM_THEMES_STORAGE_KEY, - createManagedThemeColors, createVividThemeColors, getDefaultThemeColors, themeColorToHex, @@ -92,50 +91,6 @@ describe("theme files", () => { } }); - it("derives a readable palette from extreme simple-editor colors", () => { - const light = createManagedThemeColors("light", "#111827", "#ffff00"); - const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00"); - const darkDefaults = getDefaultThemeColors("dark"); - - expect(asHex(light.canvas)).not.toBe("#111827"); - expect(asHex(dark.canvas)).not.toBe("#ffffff"); - expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeLessThan(5.5); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeLessThan(5.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeCloseTo(4.705, 1); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeCloseTo(5.082, 1); - expect(light.secondaryLabel).toBe(light.textMuted); - expect(dark.secondaryLabel).toBe(dark.textMuted); - expect(contrastRatio(light.accentForeground, light.accent)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.accentForeground, dark.accent)).toBeGreaterThanOrEqual(4.5); - // Status colors fall back to Pylon's standard red and amber rather than - // the flagship palette's, so no generated theme inherits a brand tint. - const channels = (value: string) => - [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ - number, - number, - number, - ]; - for (const colors of [light, dark]) { - const [errorRed, errorGreen, errorBlue] = channels(colors.error); - // Red leads by a wide margin; the old default was a pink whose blue sat - // close behind its red. - expect(errorRed).toBeGreaterThan(errorGreen * 2); - expect(errorRed).toBeGreaterThan(errorBlue * 2); - expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); - expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( - 4.5, - ); - const [warnRed, warnGreen, warnBlue] = channels(colors.warning); - expect(warnRed).toBeGreaterThan(warnBlue); - expect(warnGreen).toBeGreaterThan(warnBlue); - } - expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error)); - }); - it("keeps stock dark controls in the neutral-black surface hierarchy", () => { expectThemeColors(getStandardThemeColors("dark"), { canvas: "#0a0a0a", @@ -159,6 +114,12 @@ describe("theme files", () => { ["light", "#111827", "#8ab4f8"], ["dark", "#f5ecf5", "#a84370"], ]; + const channels = (value: string) => + [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ + number, + number, + number, + ]; for (const [appearance, canvas, accent] of seeds) { const colors = createVividThemeColors(appearance, canvas, accent); // Exact seeds are honored. @@ -194,6 +155,17 @@ describe("theme files", () => { expect(colors.messageAction).not.toBe(colors.accent); // Update family follows the theme, not the default palette. expect(asHex(colors.update)).toBe(accent); + // Semantic statuses stay red and amber instead of inheriting a brand tint. + const [errorRed, errorGreen, errorBlue] = channels(colors.error); + expect(errorRed).toBeGreaterThan(errorGreen * 2); + expect(errorRed).toBeGreaterThan(errorBlue * 2); + expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); + expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( + 4.5, + ); + const [warnRed, warnGreen, warnBlue] = channels(colors.warning); + expect(warnRed).toBeGreaterThan(warnBlue); + expect(warnGreen).toBeGreaterThan(warnBlue); } }); @@ -203,8 +175,8 @@ describe("theme files", () => { const inverted = [ createVividThemeColors("light", "#111827", "#8ab4f8"), createVividThemeColors("dark", "#f5ecf5", "#a84370"), - createManagedThemeColors("light", "#0d1117", "#69b1ff", { exactSeeds: true }), - createManagedThemeColors("dark", "#fdfdfd", "#c2571b", { exactSeeds: true }), + createVividThemeColors("light", "#0d1117", "#69b1ff"), + createVividThemeColors("dark", "#fdfdfd", "#c2571b"), ]; for (const colors of inverted) { expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 343e9229b7..20d5fe1c20 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -479,12 +479,6 @@ type ThemeRgbColor = { b: number; }; -type ThemeHslColor = { - h: number; - s: number; - l: number; -}; - type ThemeOklch = { L: number; C: number; h: number }; type ParsedThemeColor = { color: ThemeOklch; alpha: number }; @@ -605,48 +599,6 @@ function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition { }; } -function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor { - const red = color.r / 255; - const green = color.g / 255; - const blue = color.b / 255; - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const delta = max - min; - const lightness = (max + min) / 2; - - if (delta === 0) return { h: 0, s: 0, l: lightness }; - - const saturation = delta / (1 - Math.abs(2 * lightness - 1)); - let hue = 0; - if (max === red) hue = ((green - blue) / delta) % 6; - else if (max === green) hue = (blue - red) / delta + 2; - else hue = (red - green) / delta + 4; - - return { h: (hue * 60 + 360) % 360, s: saturation, l: lightness }; -} - -function themeHslToRgb(color: ThemeHslColor): ThemeRgbColor { - const hue = ((color.h % 360) + 360) % 360; - const chroma = (1 - Math.abs(2 * color.l - 1)) * color.s; - const hueSector = hue / 60; - const secondary = chroma * (1 - Math.abs((hueSector % 2) - 1)); - const match = color.l - chroma / 2; - const [red, green, blue] = - hueSector < 1 - ? [chroma, secondary, 0] - : hueSector < 2 - ? [secondary, chroma, 0] - : hueSector < 3 - ? [0, chroma, secondary] - : hueSector < 4 - ? [0, secondary, chroma] - : hueSector < 5 - ? [secondary, 0, chroma] - : [chroma, 0, secondary]; - - return { r: (red + match) * 255, g: (green + match) * 255, b: (blue + match) * 255 }; -} - function mixThemeRgbColors( base: ThemeRgbColor, overlay: ThemeRgbColor, @@ -1050,204 +1002,6 @@ function standardMutedThemeText( return readableThemeText(background, foreground, 1, target); } -function managedThemeBackground(value: string, appearance: ThemeAppearance): ThemeRgbColor { - const selected = parseThemeRgbColor( - value, - appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, - ); - const hsl = themeRgbToHsl(selected); - return themeHslToRgb({ - h: hsl.h, - // A background tint should support the selected mode, not turn the whole - // app into a high-saturation surface. - s: Math.min(hsl.s, appearance === "dark" ? 0.3 : 0.2), - l: - appearance === "dark" - ? Math.min(0.13, Math.max(0.07, hsl.l)) - : Math.min(0.985, Math.max(0.94, hsl.l)), - }); -} - -function managedThemeAccent( - value: string, - appearance: ThemeAppearance, - background: ThemeRgbColor, -): ThemeRgbColor { - const selected = parseThemeRgbColor(value, { r: 168, g: 67, b: 112 }); - const hsl = themeRgbToHsl(selected); - const preferredLightness = - appearance === "dark" - ? Math.min(0.72, Math.max(0.42, hsl.l)) - : Math.min(0.58, Math.max(0.35, hsl.l)); - const lightnessRange: readonly [number, number] = - appearance === "dark" ? [0.42, 0.82] : [0.22, 0.58]; - const saturation = Math.min(hsl.s, 0.82); - const candidates = Array.from({ length: 61 }, (_, index) => { - const lightness = - lightnessRange[0] + ((lightnessRange[1] - lightnessRange[0]) * index) / (61 - 1); - const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness }); - return { color, lightness, contrast: themeContrastRatio(color, background) }; - }); - // Leave a little room for browser color conversion at render time. - const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7); - const pool = readableCandidates.length > 0 ? readableCandidates : candidates; - - return pool.reduce((best, candidate) => { - const distance = Math.abs(candidate.lightness - preferredLightness); - const bestDistance = Math.abs(best.lightness - preferredLightness); - return distance < bestDistance || - (distance === bestDistance && candidate.contrast > best.contrast) - ? candidate - : best; - }).color; -} - -/** - * Creates the guided palette used by the basic theme editor. The two user - * colors control the mood, while dependent roles are generated together so - * text, surfaces, message actions, code, and terminal UI stay coherent. - */ -export function createManagedThemeColors( - appearance: ThemeAppearance, - backgroundValue: string, - accentValue: string, - options?: { - /** Use the seeds exactly as given instead of nudging them into the - * readability envelope. Derived foregrounds still adapt for contrast. */ - exactSeeds?: boolean; - }, -): ThemeColors { - const defaults = getDefaultThemeColors(appearance); - const canvas = options?.exactSeeds - ? parseThemeRgbColor( - backgroundValue, - appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, - ) - : managedThemeBackground(backgroundValue, appearance); - const accent = options?.exactSeeds - ? parseThemeRgbColor(accentValue, { r: 168, g: 67, b: 112 }) - : managedThemeAccent(accentValue, appearance, canvas); - const text = readableThemeForeground(canvas); - const textMuted = standardMutedThemeText(canvas, text); - // The top bar is part of the main panel, not a separate chrome layer: it - // shares the canvas, and its controls sit on the panel's own surfaces. - const chrome = canvas; - const sidebar = mixThemeRgbColors(canvas, accent, 0.08); - const surfaceRaised = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.12 : 0.035); - const surfaceOverlay = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.18 : 0.06); - const secondary = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.2 : 0.08); - const muted = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.13 : 0.06); - const mutedForeground = readableThemeText(muted, text, 1, 4.6); - const placeholder = readableThemeText(surfaceRaised, text, 1, 4.6); - const accentSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14); - const messageSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.36 : 0.18); - const toolbarControl = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.2 : 0.08); - const toolbarBorder = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.35 : 0.14); - const accentForeground = readableThemeForeground(accent); - // Code and terminal are large surfaces: they keep the canvas hue instead of - // drifting toward the foreground grey. Code sits just above the canvas — - // a whisper of the text tint — and the terminal sits on the canvas itself. - const codeBackground = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.06 : 0.025); - const terminalBackground = canvas; - const messageActionHover = mixThemeRgbColors( - accent, - accentForeground === THEME_LIGHT_FOREGROUND || accentForeground === THEME_WHITE_FOREGROUND - ? THEME_BLACK_FOREGROUND - : THEME_WHITE_FOREGROUND, - 0.12, - ); - - // The update family follows the accent instead of inheriting the default - // palette's brand color, so generated themes carry their own identity in - // update pills and banners. Error and warning stay semantic defaults. - const updateSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.32 : 0.16); - const updateForeground = mixThemeRgbColors( - accent, - appearance === "dark" ? THEME_WHITE_FOREGROUND : THEME_BLACK_FOREGROUND, - 0.35, - ); - return { - ...defaults, - ...standardStatusColors(canvas), - update: themeRgbToThemeColor(accent), - updateForeground: themeRgbToThemeColor(updateForeground), - updateSurface: themeRgbToThemeColor(updateSurface), - canvas: themeRgbToThemeColor(canvas), - chrome: themeRgbToThemeColor(chrome), - toolbar: themeRgbToThemeColor(chrome), - toolbarForeground: themeRgbToThemeColor(text), - toolbarBorder: themeRgbToThemeColor(toolbarBorder), - toolbarControl: themeRgbToThemeColor(toolbarControl), - toolbarControlForeground: themeRgbToThemeColor(text), - toolbarControlHover: themeRgbToThemeColor(accentSurface), - surface: themeRgbToThemeColor(canvas), - surfaceRaised: themeRgbToThemeColor(surfaceRaised), - surfaceOverlay: themeRgbToThemeColor(surfaceOverlay), - text: themeRgbToThemeColor(text), - textMuted: themeRgbToThemeColor(textMuted), - // Borders blend through the accent before lightening so control chrome - // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1) - // instead of flattening to grey. - border: themeRgbToThemeColor( - mixThemeRgbColors( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1), - text, - 0.1, - ), - ), - input: themeRgbToThemeColor( - mixThemeRgbColors( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14), - text, - appearance === "dark" ? 0.14 : 0.13, - ), - ), - focus: themeRgbToThemeColor(accent), - accent: themeRgbToThemeColor(accent), - accentForeground: themeRgbToThemeColor(accentForeground), - secondary: themeRgbToThemeColor(secondary), - secondaryForeground: themeRgbToThemeColor(readableThemeForeground(secondary)), - muted: themeRgbToThemeColor(muted), - mutedForeground: themeRgbToThemeColor(mutedForeground), - placeholder: themeRgbToThemeColor(placeholder), - secondaryLabel: themeRgbToThemeColor(textMuted), - iconMuted: themeRgbToThemeColor(textMuted), - accentSurface: themeRgbToThemeColor(accentSurface), - accentSurfaceForeground: themeRgbToThemeColor(readableThemeForeground(accentSurface)), - messageSurface: themeRgbToThemeColor(messageSurface), - messageForeground: themeRgbToThemeColor(readableThemeForeground(messageSurface)), - messageAction: themeRgbToThemeColor(accent), - messageActionForeground: themeRgbToThemeColor(accentForeground), - messageActionHover: themeRgbToThemeColor(messageActionHover), - codeBackground: themeRgbToThemeColor(codeBackground), - codeForeground: themeRgbToThemeColor(readableThemeForeground(codeBackground)), - sidebar: themeRgbToThemeColor(sidebar), - sidebarForeground: themeRgbToThemeColor(readableThemeForeground(sidebar)), - sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebar, text)), - sidebarControlSurface: themeRgbToThemeColor( - mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08), - ), - sidebarRowHover: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.12)), - sidebarRowActive: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.2)), - sidebarRowSelected: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.24)), - sidebarBorder: themeRgbToThemeColor( - mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12), - ), - terminalBackground: themeRgbToThemeColor(terminalBackground), - terminalForeground: themeRgbToThemeColor(readableThemeForeground(terminalBackground)), - terminalCursor: themeRgbToThemeColor(accent), - terminalSelection: themeRgbToThemeColor( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18), - ), - terminalScrollbar: themeRgbToThemeColor( - mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22), - ), - terminalScrollbarHover: themeRgbToThemeColor( - mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32), - ), - }; -} - /** Theme-file defaults follow the flagship palette for the requested mode. */ export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index 0cea8eb75b..f0840251bb 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -6,13 +6,9 @@ import { foldSubagentActivities, formatSubagentModelLabel, formatSubagentTokenCount, - isAgentAttributedToolActivity, isSessionAgentMessageDeliveryUnknown, - isSubagentActivityKind, - isTimelineBypassActivity, supportsSessionAgentCancel, supportsSessionAgentMessage, - workflowCardMembers, } from "./subagentRuntime.ts"; let sequence = 0; @@ -584,64 +580,6 @@ describe("deriveAgentPanelModel", () => { }); }); -describe("workflowCardMembers", () => { - it("orders by urgency (failed, running, waiting) and reports overflow", () => { - const roster = fold([ - activity("task.started", { taskId: "wf-1", taskType: "local_workflow" }), - ...[..."abcdefghij"].map((letter, index) => - activity("task.progress", { - taskId: `wf-1:wf:${index}`, - title: `agent-${letter}`, - status: index === 3 ? "failed" : index < 3 ? "completed" : "running", - ...(index === 3 ? { error: "died" } : {}), - parentAgentId: "wf-1", - agentIndex: index, - phaseIndex: 0, - phaseTitle: "Work", - }), - ), - ]); - const model = deriveAgentPanelModel({ agents: roster }); - const { visible, overflow } = workflowCardMembers(model.workflows[0]!, 8); - expect(visible).toHaveLength(8); - expect(overflow).toBe(2); - expect(visible[0]!.status).toBe("failed"); - expect(visible.filter((agent) => agent.status === "completed").length).toBeLessThanOrEqual(2); - }); -}); - -describe("timeline predicates", () => { - it("recognizes subagent activity kinds as fold input", () => { - for (const kind of [ - "task.started", - "task.progress", - "task.updated", - "task.completed", - "tool.progress", - ]) { - expect(isSubagentActivityKind(kind)).toBe(true); - } - expect(isSubagentActivityKind("tool.completed")).toBe(false); - }); - - it("attributed tool rows are re-homed; unattributed rows stay in the timeline", () => { - expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: "task-1" }))).toBe( - true, - ); - expect(isAgentAttributedToolActivity(activity("tool.completed", {}))).toBe(false); - expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: " " }))).toBe( - false, - ); - }); - - it("timelineBypass rows never render in the parent chat", () => { - expect(isTimelineBypassActivity(activity("task.progress", { timelineBypass: true }))).toBe( - true, - ); - expect(isTimelineBypassActivity(activity("task.progress", {}))).toBe(false); - }); -}); - describe("formatSubagentTokenCount", () => { it("formats plain counters", () => { expect(formatSubagentTokenCount(950)).toBe("950"); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index 39fbc7a293..029ad2bdda 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -966,61 +966,6 @@ export function deriveAgentPanelModel({ }; } -/** - * Members ordered by urgency for the capped inline workflow card: running and - * failed first, then waiting, then most recently updated. - */ -export function workflowCardMembers( - group: AgentPanelWorkflowGroup, - limit: number, -): { readonly visible: ReadonlyArray; readonly overflow: number } { - const all = [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; - const urgency = (agent: RuntimeSubagent): number => { - if (agent.status === "failed") return 0; - if (agent.status === "running") return 1; - if (agent.status === "waiting") return 2; - return 3; - }; - const ordered = all - .slice() - .sort((a, b) => urgency(a) - urgency(b) || b.updatedAt.localeCompare(a.updatedAt)); - return { - visible: ordered.slice(0, limit), - overflow: Math.max(0, ordered.length - limit), - }; -} - -/** Kinds the timeline should not render as generic rows (fold input only). */ -export function isSubagentActivityKind(kind: string): boolean { - return ( - kind === "task.started" || - kind === "task.progress" || - kind === "task.updated" || - kind === "task.completed" || - kind === "tool.progress" - ); -} - -/** - * Quiet-timeline guarantee: tool rows attributed to an owning agent belong in - * the Agents surface, not the parent chat. Unattributed rows must stay. - */ -export function isAgentAttributedToolActivity(activity: OrchestrationThreadActivity): boolean { - if (typeof activity.payload !== "object" || activity.payload === null) { - return false; - } - const payload = activity.payload as Record; - return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; -} - -/** Timeline-bypassing synthesized rows (Codex children, workflow members). */ -export function isTimelineBypassActivity(activity: OrchestrationThreadActivity): boolean { - if (typeof activity.payload !== "object" || activity.payload === null) { - return false; - } - return (activity.payload as Record).timelineBypass === true; -} - /** * Compact model chip text: strips vendor prefixes/date-or-context suffixes * ("claude-sonnet-5[1m]" → "sonnet-5[1m]", "claude-opus-4-20250514" → diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 4d38677528..63c5029148 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -7,7 +7,6 @@ import { ClientSettingsPatch, ClaudeSettings, DEFAULT_SERVER_SETTINGS, - defaultEnabledForDriver, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -329,14 +328,6 @@ describe("provider enabled defaults", () => { expect(decoded.providers.opencode.enabled).toBe(false); }); - it("derives per-driver defaults from the settings schemas", () => { - expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); - expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(false); - expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); - // Unknown fork drivers stay enabled; their own build decides otherwise. - expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); - }); - it("keeps Cursor enabled when an existing user explicitly opted in", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorId = ProviderInstanceId.make("cursor"); @@ -357,6 +348,10 @@ describe("provider enabled defaults", () => { // No flags anywhere: driver default applies. expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false); expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true); + // Unknown fork drivers stay enabled. + expect( + resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make("ollama"), config: {} }), + ).toBe(true); // Envelope flag wins over the driver default. expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true); expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe( diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c89a3b9ab1..72d65a69b4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -819,7 +819,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un export const isBuiltInDriverKind = (driver: ProviderDriverKind): boolean => Object.hasOwn(DEFAULT_SERVER_SETTINGS.providers, driver); -export const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { +const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< string, { readonly enabled?: boolean } | undefined diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 6914e1320d..02d7b173d9 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -21,16 +21,6 @@ export function formatDuration(durationMs: number): string { return `${minutes}m ${seconds}s`; } -export function formatElapsed(startIso: string, endIso: string | undefined): string | null { - if (!endIso) return null; - const startedAt = Date.parse(startIso); - const endedAt = Date.parse(endIso); - if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { - return null; - } - return formatDuration(endedAt - startedAt); -} - export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index d97685419b..016dc7064f 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -5,7 +5,6 @@ import * as Effect from "effect/Effect"; import { describe, expect, it, vi } from "vite-plus/test"; import { - extractPathFromShellOutput, CommandAvailability, type CommandAvailabilityChecker, isCommandAvailable, @@ -35,28 +34,6 @@ const withWindowsEnvironmentMocks = ( Effect.provideService(CommandAvailability, commandAvailable), ); -describe("extractPathFromShellOutput", () => { - it("extracts the path between capture markers", () => { - expect( - extractPathFromShellOutput( - "__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\n", - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("ignores shell startup noise around the capture markers", () => { - expect( - extractPathFromShellOutput( - "Welcome to fish\n__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\nBye\n", - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("returns null when the markers are missing", () => { - expect(extractPathFromShellOutput("/opt/homebrew/bin /usr/bin")).toBeNull(); - }); -}); - describe("readPathFromLoginShell", () => { it("uses a shell-agnostic printenv PATH probe", () => { const execFile = vi.fn< diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 5b813cd296..447ae759cc 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -12,8 +12,6 @@ import * as Path from "effect/Path"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; import * as Context from "effect/Context"; -const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; -const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; const WINDOWS_PATH_DELIMITER = ";"; const POSIX_PATH_DELIMITER = ":"; @@ -179,18 +177,6 @@ export function listLoginShellCandidates( return candidates; } -export function extractPathFromShellOutput(output: string): string | null { - const startIndex = output.indexOf(PATH_CAPTURE_START); - if (startIndex === -1) return null; - - const valueStartIndex = startIndex + PATH_CAPTURE_START.length; - const endIndex = output.indexOf(PATH_CAPTURE_END, valueStartIndex); - if (endIndex === -1) return null; - - const pathValue = output.slice(valueStartIndex, endIndex).trim(); - return pathValue.length > 0 ? pathValue : null; -} - export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = NodeChildProcess.execFileSync,